aicodeman 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/dist/ai-idle-checker.d.ts.map +1 -1
- package/dist/ai-idle-checker.js +3 -2
- package/dist/ai-idle-checker.js.map +1 -1
- package/dist/ai-plan-checker.d.ts.map +1 -1
- package/dist/ai-plan-checker.js +3 -2
- package/dist/ai-plan-checker.js.map +1 -1
- package/dist/bash-tool-parser.d.ts +2 -3
- package/dist/bash-tool-parser.d.ts.map +1 -1
- package/dist/bash-tool-parser.js +14 -31
- package/dist/bash-tool-parser.js.map +1 -1
- package/dist/config/ai-defaults.d.ts +16 -0
- package/dist/config/ai-defaults.d.ts.map +1 -0
- package/dist/config/ai-defaults.js +16 -0
- package/dist/config/ai-defaults.js.map +1 -0
- package/dist/config/auth-config.d.ts +19 -0
- package/dist/config/auth-config.d.ts.map +1 -0
- package/dist/config/auth-config.js +28 -0
- package/dist/config/auth-config.js.map +1 -0
- package/dist/config/exec-timeout.d.ts +10 -0
- package/dist/config/exec-timeout.d.ts.map +1 -0
- package/dist/config/exec-timeout.js +10 -0
- package/dist/config/exec-timeout.js.map +1 -0
- package/dist/config/map-limits.d.ts +4 -0
- package/dist/config/map-limits.d.ts.map +1 -1
- package/dist/config/map-limits.js +7 -0
- package/dist/config/map-limits.js.map +1 -1
- package/dist/config/server-timing.d.ts +36 -0
- package/dist/config/server-timing.d.ts.map +1 -0
- package/dist/config/server-timing.js +51 -0
- package/dist/config/server-timing.js.map +1 -0
- package/dist/config/team-config.d.ts +16 -0
- package/dist/config/team-config.d.ts.map +1 -0
- package/dist/config/team-config.js +16 -0
- package/dist/config/team-config.js.map +1 -0
- package/dist/config/terminal-limits.d.ts +18 -0
- package/dist/config/terminal-limits.d.ts.map +1 -0
- package/dist/config/terminal-limits.js +18 -0
- package/dist/config/terminal-limits.js.map +1 -0
- package/dist/config/tunnel-config.d.ts +27 -0
- package/dist/config/tunnel-config.d.ts.map +1 -0
- package/dist/config/tunnel-config.js +36 -0
- package/dist/config/tunnel-config.js.map +1 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +7 -6
- package/dist/hooks-config.js.map +1 -1
- package/dist/image-watcher.d.ts +4 -4
- package/dist/image-watcher.d.ts.map +1 -1
- package/dist/image-watcher.js +17 -30
- package/dist/image-watcher.js.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/plan-orchestrator.d.ts +2 -24
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/push-store.d.ts +1 -1
- package/dist/push-store.d.ts.map +1 -1
- package/dist/push-store.js +4 -12
- package/dist/push-store.js.map +1 -1
- package/dist/ralph-fix-plan-watcher.d.ts +91 -0
- package/dist/ralph-fix-plan-watcher.d.ts.map +1 -0
- package/dist/ralph-fix-plan-watcher.js +326 -0
- package/dist/ralph-fix-plan-watcher.js.map +1 -0
- package/dist/ralph-plan-tracker.d.ts +201 -0
- package/dist/ralph-plan-tracker.d.ts.map +1 -0
- package/dist/ralph-plan-tracker.js +325 -0
- package/dist/ralph-plan-tracker.js.map +1 -0
- package/dist/ralph-stall-detector.d.ts +84 -0
- package/dist/ralph-stall-detector.d.ts.map +1 -0
- package/dist/ralph-stall-detector.js +139 -0
- package/dist/ralph-stall-detector.js.map +1 -0
- package/dist/ralph-status-parser.d.ts +141 -0
- package/dist/ralph-status-parser.d.ts.map +1 -0
- package/dist/ralph-status-parser.js +478 -0
- package/dist/ralph-status-parser.js.map +1 -0
- package/dist/ralph-tracker.d.ts +194 -685
- package/dist/ralph-tracker.d.ts.map +1 -1
- package/dist/ralph-tracker.js +349 -1713
- package/dist/ralph-tracker.js.map +1 -1
- package/dist/respawn-adaptive-timing.d.ts +61 -0
- package/dist/respawn-adaptive-timing.d.ts.map +1 -0
- package/dist/respawn-adaptive-timing.js +105 -0
- package/dist/respawn-adaptive-timing.js.map +1 -0
- package/dist/respawn-controller.d.ts +12 -101
- package/dist/respawn-controller.d.ts.map +1 -1
- package/dist/respawn-controller.js +144 -593
- package/dist/respawn-controller.js.map +1 -1
- package/dist/respawn-health.d.ts +54 -0
- package/dist/respawn-health.d.ts.map +1 -0
- package/dist/respawn-health.js +183 -0
- package/dist/respawn-health.js.map +1 -0
- package/dist/respawn-metrics.d.ts +81 -0
- package/dist/respawn-metrics.d.ts.map +1 -0
- package/dist/respawn-metrics.js +198 -0
- package/dist/respawn-metrics.js.map +1 -0
- package/dist/respawn-patterns.d.ts +45 -0
- package/dist/respawn-patterns.d.ts.map +1 -0
- package/dist/respawn-patterns.js +125 -0
- package/dist/respawn-patterns.js.map +1 -0
- package/dist/session-auto-ops.d.ts +89 -0
- package/dist/session-auto-ops.d.ts.map +1 -0
- package/dist/session-auto-ops.js +224 -0
- package/dist/session-auto-ops.js.map +1 -0
- package/dist/session-cli-builder.d.ts +62 -0
- package/dist/session-cli-builder.d.ts.map +1 -0
- package/dist/session-cli-builder.js +121 -0
- package/dist/session-cli-builder.js.map +1 -0
- package/dist/session-task-cache.d.ts +52 -0
- package/dist/session-task-cache.d.ts.map +1 -0
- package/dist/session-task-cache.js +90 -0
- package/dist/session-task-cache.js.map +1 -0
- package/dist/session.d.ts +2 -33
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +58 -309
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +2 -2
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +12 -23
- package/dist/state-store.js.map +1 -1
- package/dist/subagent-watcher.d.ts +3 -4
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +24 -61
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/team-watcher.d.ts.map +1 -1
- package/dist/team-watcher.js +2 -5
- package/dist/team-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +1 -2
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts +26 -0
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +127 -7
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +93 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/api.js +83 -0
- package/dist/types/api.js.map +1 -0
- package/dist/types/app-state.d.ts +100 -0
- package/dist/types/app-state.d.ts.map +1 -0
- package/dist/types/app-state.js +59 -0
- package/dist/types/app-state.js.map +1 -0
- package/dist/types/common.d.ts +70 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +8 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +18 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/lifecycle.d.ts +17 -0
- package/dist/types/lifecycle.d.ts.map +1 -0
- package/dist/types/lifecycle.js +5 -0
- package/dist/types/lifecycle.js.map +1 -0
- package/dist/types/plan.d.ts +32 -0
- package/dist/types/plan.d.ts.map +1 -0
- package/dist/types/plan.js +5 -0
- package/dist/types/plan.js.map +1 -0
- package/dist/types/push.d.ts +23 -0
- package/dist/types/push.d.ts.map +1 -0
- package/dist/types/push.js +5 -0
- package/dist/types/push.js.map +1 -0
- package/dist/types/ralph.d.ts +241 -0
- package/dist/types/ralph.d.ts.map +1 -0
- package/dist/types/ralph.js +49 -0
- package/dist/types/ralph.js.map +1 -0
- package/dist/types/respawn.d.ts +250 -0
- package/dist/types/respawn.d.ts.map +1 -0
- package/dist/types/respawn.js +5 -0
- package/dist/types/respawn.js.map +1 -0
- package/dist/types/run-summary.d.ts +81 -0
- package/dist/types/run-summary.d.ts.map +1 -0
- package/dist/types/run-summary.js +22 -0
- package/dist/types/run-summary.js.map +1 -0
- package/dist/types/session.d.ts +130 -0
- package/dist/types/session.d.ts.map +1 -0
- package/dist/types/session.js +5 -0
- package/dist/types/session.js.map +1 -0
- package/dist/types/task.d.ts +58 -0
- package/dist/types/task.d.ts.map +1 -0
- package/dist/types/task.js +5 -0
- package/dist/types/task.js.map +1 -0
- package/dist/types/teams.d.ts +55 -0
- package/dist/types/teams.d.ts.map +1 -0
- package/dist/types/teams.js +5 -0
- package/dist/types/teams.js.map +1 -0
- package/dist/types/tools.d.ts +46 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +5 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/types.d.ts +1 -1138
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -214
- package/dist/types.js.map +1 -1
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -1
- package/dist/utils/claude-cli-resolver.js +1 -2
- package/dist/utils/claude-cli-resolver.js.map +1 -1
- package/dist/utils/debouncer.d.ts +111 -0
- package/dist/utils/debouncer.d.ts.map +1 -0
- package/dist/utils/debouncer.js +162 -0
- package/dist/utils/debouncer.js.map +1 -0
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -1
- package/dist/utils/opencode-cli-resolver.js +1 -2
- package/dist/utils/opencode-cli-resolver.js.map +1 -1
- package/dist/utils/string-similarity.d.ts +0 -57
- package/dist/utils/string-similarity.d.ts.map +1 -1
- package/dist/utils/string-similarity.js +3 -18
- package/dist/utils/string-similarity.js.map +1 -1
- package/dist/web/middleware/auth.d.ts +31 -0
- package/dist/web/middleware/auth.d.ts.map +1 -0
- package/dist/web/middleware/auth.js +154 -0
- package/dist/web/middleware/auth.js.map +1 -0
- package/dist/web/ports/auth-port.d.ts +18 -0
- package/dist/web/ports/auth-port.d.ts.map +1 -0
- package/dist/web/ports/auth-port.js +6 -0
- package/dist/web/ports/auth-port.js.map +1 -0
- package/dist/web/ports/config-port.d.ts +28 -0
- package/dist/web/ports/config-port.d.ts.map +1 -0
- package/dist/web/ports/config-port.js +6 -0
- package/dist/web/ports/config-port.js.map +1 -0
- package/dist/web/ports/event-port.d.ts +13 -0
- package/dist/web/ports/event-port.d.ts.map +1 -0
- package/dist/web/ports/event-port.js +6 -0
- package/dist/web/ports/event-port.js.map +1 -0
- package/dist/web/ports/index.d.ts +14 -0
- package/dist/web/ports/index.d.ts.map +1 -0
- package/dist/web/ports/index.js +9 -0
- package/dist/web/ports/index.js.map +1 -0
- package/dist/web/ports/infra-port.d.ts +36 -0
- package/dist/web/ports/infra-port.d.ts.map +1 -0
- package/dist/web/ports/infra-port.js +6 -0
- package/dist/web/ports/infra-port.js.map +1 -0
- package/dist/web/ports/respawn-port.d.ts +20 -0
- package/dist/web/ports/respawn-port.d.ts.map +1 -0
- package/dist/web/ports/respawn-port.js +6 -0
- package/dist/web/ports/respawn-port.js.map +1 -0
- package/dist/web/ports/session-port.d.ts +15 -0
- package/dist/web/ports/session-port.d.ts.map +1 -0
- package/dist/web/ports/session-port.js +6 -0
- package/dist/web/ports/session-port.js.map +1 -0
- package/dist/web/public/api-client.js +70 -0
- package/dist/web/public/api-client.js.br +0 -0
- package/dist/web/public/api-client.js.gz +0 -0
- package/dist/web/public/app.js +151 -235
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/constants.js +238 -0
- package/dist/web/public/constants.js.br +0 -0
- package/dist/web/public/constants.js.gz +0 -0
- package/dist/web/public/index.html +11 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/keyboard-accessory.js +279 -0
- package/dist/web/public/keyboard-accessory.js.br +0 -0
- package/dist/web/public/keyboard-accessory.js.gz +0 -0
- package/dist/web/public/mobile-handlers.js +467 -0
- package/dist/web/public/mobile-handlers.js.br +0 -0
- package/dist/web/public/mobile-handlers.js.gz +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/notification-manager.js +445 -0
- package/dist/web/public/notification-manager.js.br +0 -0
- package/dist/web/public/notification-manager.js.gz +0 -0
- package/dist/web/public/ralph-wizard.js +3 -3
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/subagent-windows.js +1115 -0
- package/dist/web/public/subagent-windows.js.br +0 -0
- package/dist/web/public/subagent-windows.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.js +858 -0
- package/dist/web/public/voice-input.js.br +0 -0
- package/dist/web/public/voice-input.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +38 -0
- package/dist/web/route-helpers.d.ts.map +1 -0
- package/dist/web/route-helpers.js +143 -0
- package/dist/web/route-helpers.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts +9 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -0
- package/dist/web/routes/case-routes.js +419 -0
- package/dist/web/routes/case-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts +8 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -0
- package/dist/web/routes/file-routes.js +337 -0
- package/dist/web/routes/file-routes.js.map +1 -0
- package/dist/web/routes/hook-event-routes.d.ts +9 -0
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -0
- package/dist/web/routes/hook-event-routes.js +57 -0
- package/dist/web/routes/hook-event-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +16 -0
- package/dist/web/routes/index.d.ts.map +1 -0
- package/dist/web/routes/index.js +16 -0
- package/dist/web/routes/index.js.map +1 -0
- package/dist/web/routes/mux-routes.d.ts +8 -0
- package/dist/web/routes/mux-routes.d.ts.map +1 -0
- package/dist/web/routes/mux-routes.js +32 -0
- package/dist/web/routes/mux-routes.js.map +1 -0
- package/dist/web/routes/plan-routes.d.ts +9 -0
- package/dist/web/routes/plan-routes.d.ts.map +1 -0
- package/dist/web/routes/plan-routes.js +381 -0
- package/dist/web/routes/plan-routes.js.map +1 -0
- package/dist/web/routes/push-routes.d.ts +8 -0
- package/dist/web/routes/push-routes.d.ts.map +1 -0
- package/dist/web/routes/push-routes.js +49 -0
- package/dist/web/routes/push-routes.js.map +1 -0
- package/dist/web/routes/ralph-routes.d.ts +9 -0
- package/dist/web/routes/ralph-routes.d.ts.map +1 -0
- package/dist/web/routes/ralph-routes.js +475 -0
- package/dist/web/routes/ralph-routes.js.map +1 -0
- package/dist/web/routes/respawn-routes.d.ts +8 -0
- package/dist/web/routes/respawn-routes.d.ts.map +1 -0
- package/dist/web/routes/respawn-routes.js +260 -0
- package/dist/web/routes/respawn-routes.js.map +1 -0
- package/dist/web/routes/scheduled-routes.d.ts +8 -0
- package/dist/web/routes/scheduled-routes.d.ts.map +1 -0
- package/dist/web/routes/scheduled-routes.js +51 -0
- package/dist/web/routes/scheduled-routes.js.map +1 -0
- package/dist/web/routes/session-routes.d.ts +9 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -0
- package/dist/web/routes/session-routes.js +729 -0
- package/dist/web/routes/session-routes.js.map +1 -0
- package/dist/web/routes/system-routes.d.ts +9 -0
- package/dist/web/routes/system-routes.d.ts.map +1 -0
- package/dist/web/routes/system-routes.js +678 -0
- package/dist/web/routes/system-routes.js.map +1 -0
- package/dist/web/routes/team-routes.d.ts +8 -0
- package/dist/web/routes/team-routes.d.ts.map +1 -0
- package/dist/web/routes/team-routes.js +14 -0
- package/dist/web/routes/team-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +43 -3
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +6 -2
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +10 -9
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +335 -3824
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -13,22 +13,19 @@ import Fastify from 'fastify';
|
|
|
13
13
|
import fastifyCompress from '@fastify/compress';
|
|
14
14
|
import fastifyCookie from '@fastify/cookie';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
|
-
import { join, dirname
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { existsSync,
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
|
19
19
|
import fs from 'node:fs/promises';
|
|
20
20
|
import { execSync } from 'node:child_process';
|
|
21
|
-
import {
|
|
22
|
-
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
23
22
|
import { EventEmitter } from 'node:events';
|
|
24
23
|
import { Session, } from '../session.js';
|
|
25
|
-
import { fileStreamManager } from '../file-stream-manager.js';
|
|
26
24
|
import { RespawnController } from '../respawn-controller.js';
|
|
27
25
|
import { createMultiplexer } from '../mux-factory.js';
|
|
28
26
|
import { getStore } from '../state-store.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
|
|
27
|
+
import { extractCompletionPhrase } from '../ralph-config.js';
|
|
28
|
+
import { fileStreamManager } from '../file-stream-manager.js';
|
|
32
29
|
import { subagentWatcher, } from '../subagent-watcher.js';
|
|
33
30
|
import { imageWatcher } from '../image-watcher.js';
|
|
34
31
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
|
@@ -37,7 +34,6 @@ import { TunnelManager } from '../tunnel-manager.js';
|
|
|
37
34
|
import { v4 as uuidv4 } from 'uuid';
|
|
38
35
|
import { createRequire } from 'node:module';
|
|
39
36
|
import { RunSummaryTracker } from '../run-summary.js';
|
|
40
|
-
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
|
41
37
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
|
42
38
|
import { PushSubscriptionStore } from '../push-store.js';
|
|
43
39
|
import webpush from 'web-push';
|
|
@@ -45,181 +41,18 @@ import webpush from 'web-push';
|
|
|
45
41
|
const require = createRequire(import.meta.url);
|
|
46
42
|
const { version: APP_VERSION } = require('../../package.json');
|
|
47
43
|
import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
|
|
48
|
-
import {
|
|
49
|
-
import { StaleExpirationMap } from '../utils/index.js';
|
|
44
|
+
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
|
50
45
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
|
46
|
+
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
|
47
|
+
import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, } from './routes/index.js';
|
|
51
48
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
52
|
-
|
|
53
|
-
const TERMINAL_BATCH_INTERVAL = 16;
|
|
54
|
-
// Batch task:updated events for 100ms
|
|
55
|
-
const TASK_UPDATE_BATCH_INTERVAL = 100;
|
|
49
|
+
import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEALTH_CHECK_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, } from '../config/server-timing.js';
|
|
56
50
|
// DEC mode 2026 - Synchronized Output
|
|
57
51
|
// When terminal supports this, it buffers all output between start/end markers
|
|
58
52
|
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
|
59
53
|
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
|
60
54
|
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
|
61
55
|
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
|
62
|
-
// State update debounce interval (batch expensive toDetailedState() calls)
|
|
63
|
-
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
|
64
|
-
// Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call
|
|
65
|
-
const SESSIONS_LIST_CACHE_TTL = 1000;
|
|
66
|
-
// Scheduled runs cleanup interval (check every 5 minutes)
|
|
67
|
-
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
|
68
|
-
// Completed scheduled runs max age (1 hour)
|
|
69
|
-
const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
|
70
|
-
// SSE client health check interval (every 30 seconds)
|
|
71
|
-
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
|
72
|
-
// Maximum allowed input length for session write (64KB)
|
|
73
|
-
const MAX_INPUT_LENGTH = 64 * 1024;
|
|
74
|
-
// Maximum terminal resize dimensions
|
|
75
|
-
const MAX_TERMINAL_COLS = 500;
|
|
76
|
-
const MAX_TERMINAL_ROWS = 200;
|
|
77
|
-
// Maximum session name length
|
|
78
|
-
const MAX_SESSION_NAME_LENGTH = 128;
|
|
79
|
-
// Maximum hook data size (prevents oversized SSE broadcasts)
|
|
80
|
-
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
|
81
|
-
// Maximum screenshot upload size (10MB)
|
|
82
|
-
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
|
83
|
-
// Auth session cookie TTL (24h — matches autonomous run length)
|
|
84
|
-
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
85
|
-
// Auth session cookie name
|
|
86
|
-
const AUTH_COOKIE_NAME = 'codeman_session';
|
|
87
|
-
// Max concurrent auth sessions
|
|
88
|
-
const MAX_AUTH_SESSIONS = 100;
|
|
89
|
-
// Max failed auth attempts per IP before rate-limiting
|
|
90
|
-
const AUTH_FAILURE_MAX = 10;
|
|
91
|
-
// Failed auth attempt tracking window (15 minutes)
|
|
92
|
-
const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
|
93
|
-
// Screenshots directory
|
|
94
|
-
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
|
|
95
|
-
// Stats collection interval (2 seconds)
|
|
96
|
-
const STATS_COLLECTION_INTERVAL_MS = 2000;
|
|
97
|
-
// Session limit wait time before retrying (5 seconds)
|
|
98
|
-
const SESSION_LIMIT_WAIT_MS = 5000;
|
|
99
|
-
// Pause between scheduled run iterations (2 seconds)
|
|
100
|
-
const ITERATION_PAUSE_MS = 2000;
|
|
101
|
-
// Terminal batch flush threshold - flush immediately if batch exceeds this size
|
|
102
|
-
// Set high (32KB) to allow effective batching; avg Ink events are ~14KB
|
|
103
|
-
const BATCH_FLUSH_THRESHOLD = 32 * 1024;
|
|
104
|
-
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
|
105
|
-
// eslint-disable-next-line no-control-regex
|
|
106
|
-
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
|
107
|
-
// eslint-disable-next-line no-control-regex
|
|
108
|
-
const CTRL_L_PATTERN = /\x0c/g;
|
|
109
|
-
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
|
110
|
-
/**
|
|
111
|
-
* Formats uptime in seconds to a human-readable string.
|
|
112
|
-
*/
|
|
113
|
-
function formatUptime(seconds) {
|
|
114
|
-
const days = Math.floor(seconds / 86400);
|
|
115
|
-
const hours = Math.floor((seconds % 86400) / 3600);
|
|
116
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
117
|
-
const secs = Math.floor(seconds % 60);
|
|
118
|
-
const parts = [];
|
|
119
|
-
if (days > 0)
|
|
120
|
-
parts.push(`${days}d`);
|
|
121
|
-
if (hours > 0)
|
|
122
|
-
parts.push(`${hours}h`);
|
|
123
|
-
if (minutes > 0)
|
|
124
|
-
parts.push(`${minutes}m`);
|
|
125
|
-
if (secs > 0 || parts.length === 0)
|
|
126
|
-
parts.push(`${secs}s`);
|
|
127
|
-
return parts.join(' ');
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Sanitizes hook event data before broadcasting via SSE.
|
|
131
|
-
* Extracts only relevant fields and limits total size to prevent
|
|
132
|
-
* oversized payloads from being broadcast to all connected clients.
|
|
133
|
-
*/
|
|
134
|
-
function sanitizeHookData(data) {
|
|
135
|
-
if (!data || typeof data !== 'object')
|
|
136
|
-
return {};
|
|
137
|
-
// Only forward known safe fields from Claude Code hook stdin
|
|
138
|
-
const safeFields = {};
|
|
139
|
-
const allowedKeys = [
|
|
140
|
-
'hook_event_name',
|
|
141
|
-
'tool_name',
|
|
142
|
-
'tool_input',
|
|
143
|
-
'session_id',
|
|
144
|
-
'cwd',
|
|
145
|
-
'permission_mode',
|
|
146
|
-
'stop_hook_active',
|
|
147
|
-
'transcript_path',
|
|
148
|
-
];
|
|
149
|
-
for (const key of allowedKeys) {
|
|
150
|
-
if (key in data && data[key] !== undefined) {
|
|
151
|
-
safeFields[key] = data[key];
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// For tool_input, extract only summary fields (not full file content)
|
|
155
|
-
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
|
|
156
|
-
const input = safeFields.tool_input;
|
|
157
|
-
const summary = {};
|
|
158
|
-
if (input.command)
|
|
159
|
-
summary.command = String(input.command).slice(0, 500);
|
|
160
|
-
if (input.file_path)
|
|
161
|
-
summary.file_path = String(input.file_path).slice(0, 500);
|
|
162
|
-
if (input.description)
|
|
163
|
-
summary.description = String(input.description).slice(0, 200);
|
|
164
|
-
if (input.query)
|
|
165
|
-
summary.query = String(input.query).slice(0, 200);
|
|
166
|
-
if (input.url)
|
|
167
|
-
summary.url = String(input.url).slice(0, 500);
|
|
168
|
-
if (input.pattern)
|
|
169
|
-
summary.pattern = String(input.pattern).slice(0, 200);
|
|
170
|
-
if (input.prompt)
|
|
171
|
-
summary.prompt = String(input.prompt).slice(0, 200);
|
|
172
|
-
safeFields.tool_input = summary;
|
|
173
|
-
}
|
|
174
|
-
// Final size check - drop if serialized data exceeds limit
|
|
175
|
-
const serialized = JSON.stringify(safeFields);
|
|
176
|
-
if (serialized.length > MAX_HOOK_DATA_SIZE) {
|
|
177
|
-
return { tool_name: safeFields.tool_name, _truncated: true };
|
|
178
|
-
}
|
|
179
|
-
return safeFields;
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Auto-configure Ralph tracker for a session.
|
|
183
|
-
*
|
|
184
|
-
* Priority order:
|
|
185
|
-
* 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
|
|
186
|
-
* 2. CLAUDE.md <promise> tags (fallback)
|
|
187
|
-
*
|
|
188
|
-
* The ralph-loop.local.md file has priority because it contains
|
|
189
|
-
* the exact configuration from an active Ralph loop session.
|
|
190
|
-
*/
|
|
191
|
-
function autoConfigureRalph(session, workingDir, broadcast) {
|
|
192
|
-
// First, try to read the official Ralph Wiggum plugin state file
|
|
193
|
-
const ralphConfig = parseRalphLoopConfig(workingDir);
|
|
194
|
-
if (ralphConfig && ralphConfig.completionPromise) {
|
|
195
|
-
session.ralphTracker.enable();
|
|
196
|
-
session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined);
|
|
197
|
-
// Restore iteration count if available
|
|
198
|
-
if (ralphConfig.iteration > 0) {
|
|
199
|
-
// The tracker's cycleCount will be updated when we detect iteration patterns
|
|
200
|
-
// in the terminal output, but we can set maxIterations now
|
|
201
|
-
console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
|
|
202
|
-
}
|
|
203
|
-
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`);
|
|
204
|
-
broadcast('session:ralphLoopUpdate', {
|
|
205
|
-
sessionId: session.id,
|
|
206
|
-
state: session.ralphTracker.loopState,
|
|
207
|
-
});
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
// Fallback: try CLAUDE.md
|
|
211
|
-
const claudeMdPath = join(workingDir, 'CLAUDE.md');
|
|
212
|
-
const completionPhrase = extractCompletionPhrase(claudeMdPath);
|
|
213
|
-
if (completionPhrase) {
|
|
214
|
-
session.ralphTracker.enable();
|
|
215
|
-
session.ralphTracker.startLoop(completionPhrase);
|
|
216
|
-
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
|
|
217
|
-
broadcast('session:ralphLoopUpdate', {
|
|
218
|
-
sessionId: session.id,
|
|
219
|
-
state: session.ralphTracker.loopState,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
56
|
/**
|
|
224
57
|
* Get or generate a self-signed TLS certificate for HTTPS.
|
|
225
58
|
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
|
@@ -248,8 +81,6 @@ function getOrCreateSelfSignedCert() {
|
|
|
248
81
|
};
|
|
249
82
|
}
|
|
250
83
|
export class WebServer extends EventEmitter {
|
|
251
|
-
/** Cached CPU count — doesn't change at runtime */
|
|
252
|
-
static CPU_COUNT = cpus().length;
|
|
253
84
|
app;
|
|
254
85
|
sessions = new Map();
|
|
255
86
|
respawnControllers = new Map();
|
|
@@ -277,16 +108,14 @@ export class WebServer extends EventEmitter {
|
|
|
277
108
|
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
|
|
278
109
|
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
|
279
110
|
});
|
|
280
|
-
//
|
|
281
|
-
|
|
111
|
+
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
|
112
|
+
cleanup = new CleanupManager();
|
|
282
113
|
// SSE event batching
|
|
283
114
|
taskUpdateBatches = new Map();
|
|
284
|
-
|
|
115
|
+
taskUpdateBatchTimerId = null;
|
|
285
116
|
// State update batching (reduce expensive toDetailedState() serialization)
|
|
286
117
|
stateUpdatePending = new Set();
|
|
287
|
-
|
|
288
|
-
// SSE client health check timer
|
|
289
|
-
sseHealthCheckTimer = null;
|
|
118
|
+
stateUpdateTimerId = null;
|
|
290
119
|
// Flag to prevent new timers during shutdown
|
|
291
120
|
_isStopping = false;
|
|
292
121
|
// Cached light state for SSE init (avoids rebuilding on every reconnect)
|
|
@@ -296,14 +125,13 @@ export class WebServer extends EventEmitter {
|
|
|
296
125
|
cachedSessionsList = null;
|
|
297
126
|
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
|
298
127
|
lastRecordedTokens = new Map();
|
|
299
|
-
tokenRecordingTimer = null;
|
|
300
128
|
// Server startup time for respawn grace period calculation
|
|
301
129
|
serverStartTime = Date.now();
|
|
302
130
|
// Pending respawn start timers (for cleanup on shutdown)
|
|
303
131
|
pendingRespawnStarts = new Map();
|
|
304
132
|
// Active plan orchestrators (for cancellation via API)
|
|
305
133
|
activePlanOrchestrators = new Map();
|
|
306
|
-
|
|
134
|
+
persistDeb = new KeyedDebouncer(100);
|
|
307
135
|
// Grace period before starting restored respawn controllers (2 minutes)
|
|
308
136
|
static RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
|
309
137
|
// Stored listener handlers for cleanup
|
|
@@ -312,6 +140,7 @@ export class WebServer extends EventEmitter {
|
|
|
312
140
|
tunnelManager = new TunnelManager();
|
|
313
141
|
authSessions = null;
|
|
314
142
|
authFailures = null;
|
|
143
|
+
qrAuthFailures = null;
|
|
315
144
|
pushStore = new PushSubscriptionStore();
|
|
316
145
|
teamWatcher = new TeamWatcher();
|
|
317
146
|
teamWatcherHandlers = null;
|
|
@@ -324,3562 +153,320 @@ export class WebServer extends EventEmitter {
|
|
|
324
153
|
if (https) {
|
|
325
154
|
const { key, cert } = getOrCreateSelfSignedCert();
|
|
326
155
|
this.app = Fastify({ logger: false, https: { key, cert } });
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
this.app = Fastify({ logger: false });
|
|
330
|
-
}
|
|
331
|
-
this.mux = createMultiplexer();
|
|
332
|
-
// Set up mux event listeners
|
|
333
|
-
this.mux.on('sessionCreated', (session) => {
|
|
334
|
-
this.broadcast('mux:created', session);
|
|
335
|
-
});
|
|
336
|
-
this.mux.on('sessionKilled', (data) => {
|
|
337
|
-
this.broadcast('mux:killed', data);
|
|
338
|
-
});
|
|
339
|
-
this.mux.on('sessionDied', (data) => {
|
|
340
|
-
getLifecycleLog().log({
|
|
341
|
-
event: 'mux_died',
|
|
342
|
-
sessionId: data.sessionId || 'unknown',
|
|
343
|
-
extra: data,
|
|
344
|
-
});
|
|
345
|
-
this.broadcast('mux:died', data);
|
|
346
|
-
});
|
|
347
|
-
this.mux.on('statsUpdated', (sessions) => {
|
|
348
|
-
this.broadcast('mux:statsUpdated', sessions);
|
|
349
|
-
});
|
|
350
|
-
// Set up subagent watcher listeners
|
|
351
|
-
this.setupSubagentWatcherListeners();
|
|
352
|
-
// Set up image watcher listeners
|
|
353
|
-
this.setupImageWatcherListeners();
|
|
354
|
-
// Set up team watcher listeners
|
|
355
|
-
this.setupTeamWatcherListeners();
|
|
356
|
-
// Set up tunnel manager listeners
|
|
357
|
-
this.tunnelManager.on('started', (data) => {
|
|
358
|
-
this.broadcast('tunnel:started', data);
|
|
359
|
-
});
|
|
360
|
-
this.tunnelManager.on('stopped', () => {
|
|
361
|
-
this.broadcast('tunnel:stopped', {});
|
|
362
|
-
});
|
|
363
|
-
this.tunnelManager.on('error', (message) => {
|
|
364
|
-
this.broadcast('tunnel:error', { message });
|
|
365
|
-
});
|
|
366
|
-
this.tunnelManager.on('progress', (data) => {
|
|
367
|
-
this.broadcast('tunnel:progress', data);
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Set up event listeners for subagent watcher.
|
|
372
|
-
* Broadcasts real-time subagent activity to SSE clients.
|
|
373
|
-
*
|
|
374
|
-
* The SubagentWatcher now extracts descriptions directly from the parent session's
|
|
375
|
-
* transcript, which contains the exact Task tool call with the description parameter.
|
|
376
|
-
* This is more reliable than the previous timing-based correlation approach.
|
|
377
|
-
*/
|
|
378
|
-
setupSubagentWatcherListeners() {
|
|
379
|
-
// Store handlers for cleanup on shutdown
|
|
380
|
-
this.subagentWatcherHandlers = {
|
|
381
|
-
discovered: (info) => this.broadcast('subagent:discovered', info),
|
|
382
|
-
updated: (info) => this.broadcast('subagent:updated', info),
|
|
383
|
-
toolCall: (data) => this.broadcast('subagent:tool_call', data),
|
|
384
|
-
toolResult: (data) => this.broadcast('subagent:tool_result', data),
|
|
385
|
-
progress: (data) => this.broadcast('subagent:progress', data),
|
|
386
|
-
message: (data) => this.broadcast('subagent:message', data),
|
|
387
|
-
completed: (info) => this.broadcast('subagent:completed', info),
|
|
388
|
-
error: (error, agentId) => {
|
|
389
|
-
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
|
393
|
-
subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated);
|
|
394
|
-
subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
|
395
|
-
subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
|
396
|
-
subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress);
|
|
397
|
-
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
|
398
|
-
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
|
399
|
-
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* Clean up subagent watcher listeners to prevent memory leaks.
|
|
403
|
-
*/
|
|
404
|
-
cleanupSubagentWatcherListeners() {
|
|
405
|
-
if (this.subagentWatcherHandlers) {
|
|
406
|
-
subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
|
407
|
-
subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated);
|
|
408
|
-
subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
|
409
|
-
subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
|
410
|
-
subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress);
|
|
411
|
-
subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message);
|
|
412
|
-
subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed);
|
|
413
|
-
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
|
|
414
|
-
this.subagentWatcherHandlers = null;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Set up event listeners for image watcher.
|
|
419
|
-
* Broadcasts image detection events to SSE clients for auto-popup.
|
|
420
|
-
*/
|
|
421
|
-
setupImageWatcherListeners() {
|
|
422
|
-
// Store handlers for cleanup on shutdown
|
|
423
|
-
this.imageWatcherHandlers = {
|
|
424
|
-
detected: (event) => this.broadcast('image:detected', event),
|
|
425
|
-
error: (error, sessionId) => {
|
|
426
|
-
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
|
427
|
-
},
|
|
428
|
-
};
|
|
429
|
-
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
|
430
|
-
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Clean up image watcher listeners to prevent memory leaks.
|
|
434
|
-
*/
|
|
435
|
-
cleanupImageWatcherListeners() {
|
|
436
|
-
if (this.imageWatcherHandlers) {
|
|
437
|
-
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
|
438
|
-
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
|
439
|
-
this.imageWatcherHandlers = null;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
/**
|
|
443
|
-
* Set up event listeners for team watcher.
|
|
444
|
-
* Broadcasts team activity events to SSE clients.
|
|
445
|
-
*/
|
|
446
|
-
setupTeamWatcherListeners() {
|
|
447
|
-
this.teamWatcherHandlers = {
|
|
448
|
-
teamCreated: (config) => this.broadcast('team:created', config),
|
|
449
|
-
teamUpdated: (config) => this.broadcast('team:updated', config),
|
|
450
|
-
teamRemoved: (config) => this.broadcast('team:removed', config),
|
|
451
|
-
taskUpdated: (data) => this.broadcast('team:taskUpdated', data),
|
|
452
|
-
};
|
|
453
|
-
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
|
454
|
-
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
|
455
|
-
this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
|
456
|
-
this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Clean up team watcher listeners to prevent memory leaks.
|
|
460
|
-
*/
|
|
461
|
-
cleanupTeamWatcherListeners() {
|
|
462
|
-
if (this.teamWatcherHandlers) {
|
|
463
|
-
this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
|
|
464
|
-
this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
|
465
|
-
this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
|
466
|
-
this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
|
467
|
-
this.teamWatcherHandlers = null;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
async setupRoutes() {
|
|
471
|
-
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
|
|
472
|
-
// so the route handler can read the raw stream directly.
|
|
473
|
-
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
|
|
474
|
-
done(null);
|
|
475
|
-
});
|
|
476
|
-
// Enable gzip/brotli compression for all responses.
|
|
477
|
-
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
|
|
478
|
-
// Threshold 1024 = don't compress tiny responses (headers > savings).
|
|
479
|
-
await this.app.register(fastifyCompress, {
|
|
480
|
-
threshold: 1024,
|
|
481
|
-
});
|
|
482
|
-
// Cookie plugin (needed for auth session tokens)
|
|
483
|
-
await this.app.register(fastifyCookie);
|
|
484
|
-
// Optional HTTP Basic Auth with session cookies and rate limiting
|
|
485
|
-
const authPassword = process.env.CODEMAN_PASSWORD;
|
|
486
|
-
if (authPassword) {
|
|
487
|
-
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
|
488
|
-
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
|
489
|
-
// Session token store — active sessions extend TTL on access
|
|
490
|
-
this.authSessions = new StaleExpirationMap({
|
|
491
|
-
ttlMs: AUTH_SESSION_TTL_MS,
|
|
492
|
-
refreshOnGet: true,
|
|
493
|
-
});
|
|
494
|
-
// Failure counter per IP — decay naturally after 15 minutes
|
|
495
|
-
this.authFailures = new StaleExpirationMap({
|
|
496
|
-
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
|
497
|
-
refreshOnGet: false,
|
|
498
|
-
});
|
|
499
|
-
this.app.addHook('onRequest', (req, reply, done) => {
|
|
500
|
-
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
|
501
|
-
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
|
502
|
-
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
|
|
503
|
-
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
|
504
|
-
const ip = req.ip;
|
|
505
|
-
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
|
|
506
|
-
done();
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
// Non-localhost hook requests fall through to normal auth
|
|
510
|
-
}
|
|
511
|
-
const clientIp = req.ip;
|
|
512
|
-
// Rate limit: reject if too many failed attempts from this IP
|
|
513
|
-
const failures = this.authFailures.get(clientIp) ?? 0;
|
|
514
|
-
if (failures >= AUTH_FAILURE_MAX) {
|
|
515
|
-
reply.code(429).send('Too Many Requests — try again later');
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
// Check session cookie first (avoids re-sending credentials on every request)
|
|
519
|
-
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
|
520
|
-
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
|
521
|
-
if (sessionToken && this.authSessions.get(sessionToken) !== undefined) {
|
|
522
|
-
done();
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
// Check Basic Auth header (timing-safe comparison to prevent side-channel attacks)
|
|
526
|
-
const auth = req.headers.authorization;
|
|
527
|
-
const authBuf = Buffer.from(auth ?? '');
|
|
528
|
-
const expectedBuf = Buffer.from(expectedHeader);
|
|
529
|
-
if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) {
|
|
530
|
-
// Issue session token cookie so browser doesn't need to re-send credentials
|
|
531
|
-
const token = randomBytes(32).toString('hex');
|
|
532
|
-
// Evict oldest if at capacity (prevent unbounded growth)
|
|
533
|
-
if (this.authSessions.size >= MAX_AUTH_SESSIONS) {
|
|
534
|
-
const oldestKey = this.authSessions.keys().next().value;
|
|
535
|
-
if (oldestKey !== undefined)
|
|
536
|
-
this.authSessions.delete(oldestKey);
|
|
537
|
-
}
|
|
538
|
-
this.authSessions.set(token, clientIp);
|
|
539
|
-
// Reset failure count on successful auth
|
|
540
|
-
this.authFailures.delete(clientIp);
|
|
541
|
-
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
|
542
|
-
httpOnly: true,
|
|
543
|
-
secure: this.https,
|
|
544
|
-
sameSite: 'lax',
|
|
545
|
-
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
|
|
546
|
-
path: '/',
|
|
547
|
-
});
|
|
548
|
-
done();
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
// Auth failed — track failure count
|
|
552
|
-
this.authFailures.set(clientIp, failures + 1);
|
|
553
|
-
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
|
554
|
-
reply.code(401).send('Unauthorized');
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
// Security headers + CORS on every response
|
|
558
|
-
this.app.addHook('onRequest', (req, reply, done) => {
|
|
559
|
-
reply.header('X-Content-Type-Options', 'nosniff');
|
|
560
|
-
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
|
561
|
-
reply.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'");
|
|
562
|
-
if (this.https) {
|
|
563
|
-
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
564
|
-
}
|
|
565
|
-
// CORS: restrict to same-origin (localhost) only
|
|
566
|
-
const origin = req.headers.origin;
|
|
567
|
-
if (origin) {
|
|
568
|
-
try {
|
|
569
|
-
const url = new URL(origin);
|
|
570
|
-
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
|
|
571
|
-
reply.header('Access-Control-Allow-Origin', origin);
|
|
572
|
-
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
573
|
-
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
574
|
-
reply.header('Access-Control-Max-Age', '86400');
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
catch {
|
|
578
|
-
// Invalid origin header — do not set CORS headers
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// Handle CORS preflight
|
|
582
|
-
if (req.method === 'OPTIONS') {
|
|
583
|
-
reply.code(204).send();
|
|
584
|
-
done();
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
done();
|
|
588
|
-
});
|
|
589
|
-
// Service worker must never be cached — browsers check for SW updates on navigation
|
|
590
|
-
this.app.get('/sw.js', async (_req, reply) => {
|
|
591
|
-
return reply
|
|
592
|
-
.header('Cache-Control', 'no-cache, no-store')
|
|
593
|
-
.header('Service-Worker-Allowed', '/')
|
|
594
|
-
.type('application/javascript')
|
|
595
|
-
.sendFile('sw.js', join(__dirname, 'public'));
|
|
596
|
-
});
|
|
597
|
-
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
|
|
598
|
-
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
|
599
|
-
await this.app.register(fastifyStatic, {
|
|
600
|
-
root: join(__dirname, 'public'),
|
|
601
|
-
prefix: '/',
|
|
602
|
-
maxAge: '1y',
|
|
603
|
-
immutable: true,
|
|
604
|
-
preCompressed: true,
|
|
605
|
-
});
|
|
606
|
-
// SSE endpoint for real-time updates
|
|
607
|
-
this.app.get('/api/events', (req, reply) => {
|
|
608
|
-
// Enforce SSE client limit to prevent memory exhaustion from too many connections
|
|
609
|
-
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
|
|
610
|
-
reply.code(503).send('Too many SSE connections');
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
reply.raw.writeHead(200, {
|
|
614
|
-
'Content-Type': 'text/event-stream',
|
|
615
|
-
'Cache-Control': 'no-cache',
|
|
616
|
-
Connection: 'keep-alive',
|
|
617
|
-
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
618
|
-
});
|
|
619
|
-
this.sseClients.add(reply);
|
|
620
|
-
// Send initial state
|
|
621
|
-
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
|
622
|
-
// Buffers are fetched on-demand when switching tabs
|
|
623
|
-
this.sendSSE(reply, 'init', this.getLightState());
|
|
624
|
-
req.raw.on('close', () => {
|
|
625
|
-
this.sseClients.delete(reply);
|
|
626
|
-
this.backpressuredClients.delete(reply);
|
|
627
|
-
});
|
|
628
|
-
});
|
|
629
|
-
// API Routes
|
|
630
|
-
// Logout: invalidate session cookie
|
|
631
|
-
this.app.post('/api/logout', async (req, reply) => {
|
|
632
|
-
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
|
633
|
-
if (sessionToken && this.authSessions) {
|
|
634
|
-
this.authSessions.delete(sessionToken);
|
|
635
|
-
}
|
|
636
|
-
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
|
637
|
-
return { success: true };
|
|
638
|
-
});
|
|
639
|
-
this.app.get('/api/status', async () => this.getLightState());
|
|
640
|
-
this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus());
|
|
641
|
-
this.app.get('/api/tunnel/qr', async (_req, reply) => {
|
|
642
|
-
const url = this.tunnelManager.getUrl();
|
|
643
|
-
if (!url) {
|
|
644
|
-
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
|
645
|
-
}
|
|
646
|
-
try {
|
|
647
|
-
const QRCode = require('qrcode');
|
|
648
|
-
const svg = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
|
649
|
-
// Return as data URI to avoid Fastify compress issues with SVG content-type
|
|
650
|
-
return { svg };
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
// OpenCode CLI availability check
|
|
657
|
-
this.app.get('/api/opencode/status', async () => {
|
|
658
|
-
const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js');
|
|
659
|
-
return {
|
|
660
|
-
available: isOpenCodeAvailable(),
|
|
661
|
-
path: resolveOpenCodeDir(),
|
|
662
|
-
};
|
|
663
|
-
});
|
|
664
|
-
// Cleanup stale sessions from state file
|
|
665
|
-
this.app.post('/api/cleanup-state', async () => {
|
|
666
|
-
const cleaned = this.cleanupStaleSessions();
|
|
667
|
-
return { success: true, cleanedSessions: cleaned };
|
|
668
|
-
});
|
|
669
|
-
// Session lifecycle audit log
|
|
670
|
-
this.app.get('/api/session-lifecycle', async (req) => {
|
|
671
|
-
const query = req.query;
|
|
672
|
-
const lifecycleLog = getLifecycleLog();
|
|
673
|
-
const entries = await lifecycleLog.query({
|
|
674
|
-
sessionId: query.sessionId,
|
|
675
|
-
event: query.event,
|
|
676
|
-
since: query.since ? Number(query.since) : undefined,
|
|
677
|
-
limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
|
|
678
|
-
});
|
|
679
|
-
return { success: true, entries };
|
|
680
|
-
});
|
|
681
|
-
// Global stats endpoint
|
|
682
|
-
this.app.get('/api/stats', async () => {
|
|
683
|
-
const activeSessionTokens = {};
|
|
684
|
-
for (const [sessionId, session] of this.sessions) {
|
|
685
|
-
activeSessionTokens[sessionId] = {
|
|
686
|
-
inputTokens: session.inputTokens,
|
|
687
|
-
outputTokens: session.outputTokens,
|
|
688
|
-
totalCost: session.totalCost,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
return {
|
|
692
|
-
success: true,
|
|
693
|
-
stats: this.store.getAggregateStats(activeSessionTokens),
|
|
694
|
-
raw: this.store.getGlobalStats(),
|
|
695
|
-
};
|
|
696
|
-
});
|
|
697
|
-
// Token stats with daily history
|
|
698
|
-
this.app.get('/api/token-stats', async () => {
|
|
699
|
-
// Get aggregate totals (global + active sessions)
|
|
700
|
-
const activeSessionTokens = {};
|
|
701
|
-
for (const [sessionId, session] of this.sessions) {
|
|
702
|
-
activeSessionTokens[sessionId] = {
|
|
703
|
-
inputTokens: session.inputTokens,
|
|
704
|
-
outputTokens: session.outputTokens,
|
|
705
|
-
totalCost: session.totalCost,
|
|
706
|
-
};
|
|
707
|
-
}
|
|
708
|
-
return {
|
|
709
|
-
success: true,
|
|
710
|
-
daily: this.store.getDailyStats(30),
|
|
711
|
-
totals: this.store.getAggregateStats(activeSessionTokens),
|
|
712
|
-
};
|
|
713
|
-
});
|
|
714
|
-
this.app.get('/api/config', async () => {
|
|
715
|
-
return { success: true, config: this.store.getConfig() };
|
|
716
|
-
});
|
|
717
|
-
this.app.put('/api/config', async (req) => {
|
|
718
|
-
// Validate request body against schema to prevent arbitrary config injection
|
|
719
|
-
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
|
720
|
-
if (!parseResult.success) {
|
|
721
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
|
722
|
-
}
|
|
723
|
-
this.store.setConfig(parseResult.data);
|
|
724
|
-
return { success: true, config: this.store.getConfig() };
|
|
725
|
-
});
|
|
726
|
-
// Debug/monitoring endpoint - lightweight, only runs when called
|
|
727
|
-
// Returns comprehensive memory metrics for debugging memory leaks
|
|
728
|
-
this.app.get('/api/debug/memory', async () => {
|
|
729
|
-
const mem = process.memoryUsage();
|
|
730
|
-
const subagentStats = subagentWatcher.getStats();
|
|
731
|
-
// Calculate total Map entries for memory estimation
|
|
732
|
-
const serverMapSizes = {
|
|
733
|
-
sessions: this.sessions.size,
|
|
734
|
-
sseClients: this.sseClients.size,
|
|
735
|
-
respawnControllers: this.respawnControllers.size,
|
|
736
|
-
runSummaryTrackers: this.runSummaryTrackers.size,
|
|
737
|
-
transcriptWatchers: this.transcriptWatchers.size,
|
|
738
|
-
scheduledRuns: this.scheduledRuns.size,
|
|
739
|
-
terminalBatches: this.terminalBatches.size,
|
|
740
|
-
taskUpdateBatches: this.taskUpdateBatches.size,
|
|
741
|
-
stateUpdatePending: this.stateUpdatePending.size,
|
|
742
|
-
lastRecordedTokens: this.lastRecordedTokens.size,
|
|
743
|
-
pendingRespawnStarts: this.pendingRespawnStarts.size,
|
|
744
|
-
respawnTimers: this.respawnTimers.size,
|
|
745
|
-
activePlanOrchestrators: this.activePlanOrchestrators.size,
|
|
746
|
-
cleaningUp: this.cleaningUp.size,
|
|
747
|
-
};
|
|
748
|
-
const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0);
|
|
749
|
-
const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0);
|
|
750
|
-
return {
|
|
751
|
-
memory: {
|
|
752
|
-
rss: mem.rss,
|
|
753
|
-
rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10,
|
|
754
|
-
heapUsed: mem.heapUsed,
|
|
755
|
-
heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10,
|
|
756
|
-
heapTotal: mem.heapTotal,
|
|
757
|
-
heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10,
|
|
758
|
-
external: mem.external,
|
|
759
|
-
externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10,
|
|
760
|
-
arrayBuffers: mem.arrayBuffers,
|
|
761
|
-
arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10,
|
|
762
|
-
},
|
|
763
|
-
mapSizes: {
|
|
764
|
-
server: serverMapSizes,
|
|
765
|
-
subagentWatcher: subagentStats,
|
|
766
|
-
totals: {
|
|
767
|
-
serverEntries: totalServerMapEntries,
|
|
768
|
-
subagentEntries: totalSubagentMapEntries,
|
|
769
|
-
allEntries: totalServerMapEntries + totalSubagentMapEntries,
|
|
770
|
-
},
|
|
771
|
-
},
|
|
772
|
-
watchers: {
|
|
773
|
-
fileDebouncers: subagentStats.fileDebouncerCount,
|
|
774
|
-
dirWatchers: subagentStats.dirWatcherCount,
|
|
775
|
-
transcriptWatchers: this.transcriptWatchers.size,
|
|
776
|
-
total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
|
|
777
|
-
},
|
|
778
|
-
timers: {
|
|
779
|
-
respawnTimers: this.respawnTimers.size,
|
|
780
|
-
pendingRespawnStarts: this.pendingRespawnStarts.size,
|
|
781
|
-
subagentIdleTimers: subagentStats.idleTimerCount,
|
|
782
|
-
total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount,
|
|
783
|
-
},
|
|
784
|
-
uptime: {
|
|
785
|
-
seconds: Math.round(process.uptime()),
|
|
786
|
-
formatted: formatUptime(process.uptime()),
|
|
787
|
-
},
|
|
788
|
-
timestamp: Date.now(),
|
|
789
|
-
};
|
|
790
|
-
});
|
|
791
|
-
// Session management
|
|
792
|
-
this.app.get('/api/sessions', async () => this.getLightSessionsState());
|
|
793
|
-
this.app.post('/api/sessions', async (req) => {
|
|
794
|
-
// Prevent unbounded session creation
|
|
795
|
-
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
796
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
|
|
797
|
-
}
|
|
798
|
-
const result = CreateSessionSchema.safeParse(req.body);
|
|
799
|
-
if (!result.success) {
|
|
800
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
801
|
-
}
|
|
802
|
-
const body = result.data;
|
|
803
|
-
const workingDir = body.workingDir || process.cwd();
|
|
804
|
-
// Validate workingDir exists and is a directory
|
|
805
|
-
if (body.workingDir) {
|
|
806
|
-
try {
|
|
807
|
-
const stat = statSync(workingDir);
|
|
808
|
-
if (!stat.isDirectory()) {
|
|
809
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
catch {
|
|
813
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
// Write env overrides to .claude/settings.local.json if provided
|
|
817
|
-
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
|
818
|
-
await updateCaseEnvVars(workingDir, body.envOverrides);
|
|
819
|
-
}
|
|
820
|
-
// Check OpenCode availability if requested
|
|
821
|
-
if (body.mode === 'opencode') {
|
|
822
|
-
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
|
823
|
-
if (!isOpenCodeAvailable()) {
|
|
824
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
const globalNice = await this.getGlobalNiceConfig();
|
|
828
|
-
const modelConfig = await this.getModelConfig();
|
|
829
|
-
const mode = body.mode || 'claude';
|
|
830
|
-
const model = mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
|
831
|
-
const claudeModeConfig = await this.getClaudeModeConfig();
|
|
832
|
-
const session = new Session({
|
|
833
|
-
workingDir,
|
|
834
|
-
mode,
|
|
835
|
-
name: body.name || '',
|
|
836
|
-
mux: this.mux,
|
|
837
|
-
useMux: true,
|
|
838
|
-
niceConfig: globalNice,
|
|
839
|
-
model,
|
|
840
|
-
claudeMode: claudeModeConfig.claudeMode,
|
|
841
|
-
allowedTools: claudeModeConfig.allowedTools,
|
|
842
|
-
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
|
843
|
-
});
|
|
844
|
-
this.sessions.set(session.id, session);
|
|
845
|
-
this.store.incrementSessionsCreated();
|
|
846
|
-
this.persistSessionState(session);
|
|
847
|
-
await this.setupSessionListeners(session);
|
|
848
|
-
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
|
849
|
-
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
|
850
|
-
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
|
851
|
-
const lightState = this.getSessionStateWithRespawn(session);
|
|
852
|
-
this.broadcast('session:created', lightState);
|
|
853
|
-
return { success: true, session: lightState };
|
|
854
|
-
});
|
|
855
|
-
// Rename a session
|
|
856
|
-
this.app.put('/api/sessions/:id/name', async (req) => {
|
|
857
|
-
const { id } = req.params;
|
|
858
|
-
const result = SessionNameSchema.safeParse(req.body);
|
|
859
|
-
if (!result.success) {
|
|
860
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
861
|
-
}
|
|
862
|
-
const body = result.data;
|
|
863
|
-
const session = this.sessions.get(id);
|
|
864
|
-
if (!session) {
|
|
865
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
866
|
-
}
|
|
867
|
-
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
|
868
|
-
session.name = name;
|
|
869
|
-
// Also update the mux session name if applicable
|
|
870
|
-
this.mux.updateSessionName(id, session.name);
|
|
871
|
-
this.persistSessionState(session);
|
|
872
|
-
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
|
873
|
-
return { success: true, name: session.name };
|
|
874
|
-
});
|
|
875
|
-
// Set session color
|
|
876
|
-
this.app.put('/api/sessions/:id/color', async (req) => {
|
|
877
|
-
const { id } = req.params;
|
|
878
|
-
const result = SessionColorSchema.safeParse(req.body);
|
|
879
|
-
if (!result.success) {
|
|
880
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
881
|
-
}
|
|
882
|
-
const body = result.data;
|
|
883
|
-
const session = this.sessions.get(id);
|
|
884
|
-
if (!session) {
|
|
885
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
886
|
-
}
|
|
887
|
-
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
|
888
|
-
if (!validColors.includes(body.color)) {
|
|
889
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
|
|
890
|
-
}
|
|
891
|
-
session.setColor(body.color);
|
|
892
|
-
this.persistSessionState(session);
|
|
893
|
-
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
|
894
|
-
return { success: true, color: session.color };
|
|
895
|
-
});
|
|
896
|
-
this.app.delete('/api/sessions/:id', async (req) => {
|
|
897
|
-
const { id } = req.params;
|
|
898
|
-
const query = req.query;
|
|
899
|
-
const killMux = query.killMux !== 'false'; // Default to true
|
|
900
|
-
if (!this.sessions.has(id)) {
|
|
901
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
902
|
-
}
|
|
903
|
-
await this.cleanupSession(id, killMux, 'user_delete');
|
|
904
|
-
return { success: true };
|
|
905
|
-
});
|
|
906
|
-
// Kill all sessions at once
|
|
907
|
-
this.app.delete('/api/sessions', async () => {
|
|
908
|
-
const sessionIds = Array.from(this.sessions.keys());
|
|
909
|
-
let killed = 0;
|
|
910
|
-
for (const id of sessionIds) {
|
|
911
|
-
if (this.sessions.has(id)) {
|
|
912
|
-
await this.cleanupSession(id, true, 'user_bulk_delete');
|
|
913
|
-
killed++;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
return { success: true, data: { killed } };
|
|
917
|
-
});
|
|
918
|
-
this.app.get('/api/sessions/:id', async (req) => {
|
|
919
|
-
const { id } = req.params;
|
|
920
|
-
const session = this.sessions.get(id);
|
|
921
|
-
if (!session) {
|
|
922
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
923
|
-
}
|
|
924
|
-
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
|
925
|
-
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
|
926
|
-
return this.getSessionStateWithRespawn(session);
|
|
927
|
-
});
|
|
928
|
-
this.app.get('/api/sessions/:id/output', async (req) => {
|
|
929
|
-
const { id } = req.params;
|
|
930
|
-
const session = this.sessions.get(id);
|
|
931
|
-
if (!session) {
|
|
932
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
933
|
-
}
|
|
934
|
-
return {
|
|
935
|
-
success: true,
|
|
936
|
-
data: {
|
|
937
|
-
textOutput: session.textOutput,
|
|
938
|
-
messages: session.messages,
|
|
939
|
-
errorBuffer: session.errorBuffer,
|
|
940
|
-
},
|
|
941
|
-
};
|
|
942
|
-
});
|
|
943
|
-
// Get Ralph state (Ralph loop + todos) for a session
|
|
944
|
-
this.app.get('/api/sessions/:id/ralph-state', async (req) => {
|
|
945
|
-
const { id } = req.params;
|
|
946
|
-
const session = this.sessions.get(id);
|
|
947
|
-
if (!session) {
|
|
948
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
949
|
-
}
|
|
950
|
-
return {
|
|
951
|
-
success: true,
|
|
952
|
-
data: {
|
|
953
|
-
loop: session.ralphLoopState,
|
|
954
|
-
todos: session.ralphTodos,
|
|
955
|
-
todoStats: session.ralphTodoStats,
|
|
956
|
-
},
|
|
957
|
-
};
|
|
958
|
-
});
|
|
959
|
-
// Get run summary for a session (what happened while you were away)
|
|
960
|
-
this.app.get('/api/sessions/:id/run-summary', async (req) => {
|
|
961
|
-
const { id } = req.params;
|
|
962
|
-
const session = this.sessions.get(id);
|
|
963
|
-
if (!session) {
|
|
964
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
965
|
-
}
|
|
966
|
-
const tracker = this.runSummaryTrackers.get(id);
|
|
967
|
-
if (!tracker) {
|
|
968
|
-
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
|
969
|
-
const newTracker = new RunSummaryTracker(id, session.name);
|
|
970
|
-
this.runSummaryTrackers.set(id, newTracker);
|
|
971
|
-
return { success: true, summary: newTracker.getSummary() };
|
|
972
|
-
}
|
|
973
|
-
// Update session name in case it changed
|
|
974
|
-
tracker.setSessionName(session.name);
|
|
975
|
-
return { success: true, summary: tracker.getSummary() };
|
|
976
|
-
});
|
|
977
|
-
// Get active Bash tools for a session (file-viewing commands)
|
|
978
|
-
this.app.get('/api/sessions/:id/active-tools', async (req) => {
|
|
979
|
-
const { id } = req.params;
|
|
980
|
-
const session = this.sessions.get(id);
|
|
981
|
-
if (!session) {
|
|
982
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
983
|
-
}
|
|
984
|
-
return {
|
|
985
|
-
success: true,
|
|
986
|
-
data: {
|
|
987
|
-
tools: session.activeTools,
|
|
988
|
-
},
|
|
989
|
-
};
|
|
990
|
-
});
|
|
991
|
-
// Get file tree for session's working directory (File Browser)
|
|
992
|
-
this.app.get('/api/sessions/:id/files', async (req) => {
|
|
993
|
-
const { id } = req.params;
|
|
994
|
-
const { depth, showHidden } = req.query;
|
|
995
|
-
const session = this.sessions.get(id);
|
|
996
|
-
if (!session) {
|
|
997
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
998
|
-
}
|
|
999
|
-
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
|
1000
|
-
const includeHidden = showHidden === 'true';
|
|
1001
|
-
const workingDir = session.workingDir;
|
|
1002
|
-
// Default excludes - large/generated directories
|
|
1003
|
-
const excludeDirs = new Set([
|
|
1004
|
-
'.git',
|
|
1005
|
-
'node_modules',
|
|
1006
|
-
'dist',
|
|
1007
|
-
'build',
|
|
1008
|
-
'__pycache__',
|
|
1009
|
-
'.cache',
|
|
1010
|
-
'.next',
|
|
1011
|
-
'.nuxt',
|
|
1012
|
-
'coverage',
|
|
1013
|
-
'.venv',
|
|
1014
|
-
'venv',
|
|
1015
|
-
'.tox',
|
|
1016
|
-
'target',
|
|
1017
|
-
'vendor',
|
|
1018
|
-
]);
|
|
1019
|
-
let totalFiles = 0;
|
|
1020
|
-
let totalDirectories = 0;
|
|
1021
|
-
let truncated = false;
|
|
1022
|
-
const maxFiles = 5000;
|
|
1023
|
-
const scanDirectory = async (dirPath, currentDepth) => {
|
|
1024
|
-
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
|
1025
|
-
truncated = true;
|
|
1026
|
-
return [];
|
|
1027
|
-
}
|
|
1028
|
-
try {
|
|
1029
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1030
|
-
const nodes = [];
|
|
1031
|
-
// Sort: directories first, then alphabetically
|
|
1032
|
-
entries.sort((a, b) => {
|
|
1033
|
-
if (a.isDirectory() && !b.isDirectory())
|
|
1034
|
-
return -1;
|
|
1035
|
-
if (!a.isDirectory() && b.isDirectory())
|
|
1036
|
-
return 1;
|
|
1037
|
-
return a.name.localeCompare(b.name);
|
|
1038
|
-
});
|
|
1039
|
-
for (const entry of entries) {
|
|
1040
|
-
if (totalFiles + totalDirectories > maxFiles) {
|
|
1041
|
-
truncated = true;
|
|
1042
|
-
break;
|
|
1043
|
-
}
|
|
1044
|
-
// Skip hidden files unless requested
|
|
1045
|
-
if (!includeHidden && entry.name.startsWith('.'))
|
|
1046
|
-
continue;
|
|
1047
|
-
// Skip excluded directories
|
|
1048
|
-
if (entry.isDirectory() && excludeDirs.has(entry.name))
|
|
1049
|
-
continue;
|
|
1050
|
-
const fullPath = join(dirPath, entry.name);
|
|
1051
|
-
const relativePath = fullPath.slice(workingDir.length + 1);
|
|
1052
|
-
if (entry.isDirectory()) {
|
|
1053
|
-
totalDirectories++;
|
|
1054
|
-
const children = await scanDirectory(fullPath, currentDepth + 1);
|
|
1055
|
-
nodes.push({
|
|
1056
|
-
name: entry.name,
|
|
1057
|
-
path: relativePath,
|
|
1058
|
-
type: 'directory',
|
|
1059
|
-
children,
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
else {
|
|
1063
|
-
totalFiles++;
|
|
1064
|
-
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
|
|
1065
|
-
let size;
|
|
1066
|
-
try {
|
|
1067
|
-
const stat = await fs.stat(fullPath);
|
|
1068
|
-
size = stat.size;
|
|
1069
|
-
}
|
|
1070
|
-
catch {
|
|
1071
|
-
// Skip if can't stat
|
|
1072
|
-
}
|
|
1073
|
-
nodes.push({
|
|
1074
|
-
name: entry.name,
|
|
1075
|
-
path: relativePath,
|
|
1076
|
-
type: 'file',
|
|
1077
|
-
size,
|
|
1078
|
-
extension: ext,
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
return nodes;
|
|
1083
|
-
}
|
|
1084
|
-
catch (err) {
|
|
1085
|
-
// Can't read directory (permission denied, etc.)
|
|
1086
|
-
return [];
|
|
1087
|
-
}
|
|
1088
|
-
};
|
|
1089
|
-
const tree = await scanDirectory(workingDir, 1);
|
|
1090
|
-
return {
|
|
1091
|
-
success: true,
|
|
1092
|
-
data: {
|
|
1093
|
-
root: workingDir,
|
|
1094
|
-
tree,
|
|
1095
|
-
totalFiles,
|
|
1096
|
-
totalDirectories,
|
|
1097
|
-
truncated,
|
|
1098
|
-
},
|
|
1099
|
-
};
|
|
1100
|
-
});
|
|
1101
|
-
// Get file content for preview (File Browser)
|
|
1102
|
-
this.app.get('/api/sessions/:id/file-content', async (req) => {
|
|
1103
|
-
const { id } = req.params;
|
|
1104
|
-
const { path: filePath, lines, raw } = req.query;
|
|
1105
|
-
const session = this.sessions.get(id);
|
|
1106
|
-
if (!session) {
|
|
1107
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1108
|
-
}
|
|
1109
|
-
if (!filePath) {
|
|
1110
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
|
1111
|
-
}
|
|
1112
|
-
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
|
1113
|
-
const fullPath = resolve(session.workingDir, filePath);
|
|
1114
|
-
let resolvedPath;
|
|
1115
|
-
try {
|
|
1116
|
-
resolvedPath = realpathSync(fullPath);
|
|
1117
|
-
}
|
|
1118
|
-
catch {
|
|
1119
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
|
1120
|
-
}
|
|
1121
|
-
const relativePath = relative(session.workingDir, resolvedPath);
|
|
1122
|
-
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
1123
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
|
|
1124
|
-
}
|
|
1125
|
-
try {
|
|
1126
|
-
const stat = await fs.stat(resolvedPath);
|
|
1127
|
-
// Check if it's a binary/media file
|
|
1128
|
-
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
1129
|
-
const binaryExts = new Set([
|
|
1130
|
-
'png',
|
|
1131
|
-
'jpg',
|
|
1132
|
-
'jpeg',
|
|
1133
|
-
'gif',
|
|
1134
|
-
'webp',
|
|
1135
|
-
'ico',
|
|
1136
|
-
'svg',
|
|
1137
|
-
'bmp',
|
|
1138
|
-
'mp4',
|
|
1139
|
-
'webm',
|
|
1140
|
-
'mov',
|
|
1141
|
-
'avi',
|
|
1142
|
-
'mp3',
|
|
1143
|
-
'wav',
|
|
1144
|
-
'ogg',
|
|
1145
|
-
'pdf',
|
|
1146
|
-
'zip',
|
|
1147
|
-
'tar',
|
|
1148
|
-
'gz',
|
|
1149
|
-
'exe',
|
|
1150
|
-
'dll',
|
|
1151
|
-
'so',
|
|
1152
|
-
'woff',
|
|
1153
|
-
'woff2',
|
|
1154
|
-
'ttf',
|
|
1155
|
-
'eot',
|
|
1156
|
-
]);
|
|
1157
|
-
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
|
1158
|
-
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
|
|
1159
|
-
if (raw === 'true' || binaryExts.has(ext)) {
|
|
1160
|
-
// Return metadata for binary files
|
|
1161
|
-
return {
|
|
1162
|
-
success: true,
|
|
1163
|
-
data: {
|
|
1164
|
-
path: filePath,
|
|
1165
|
-
size: stat.size,
|
|
1166
|
-
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
|
|
1167
|
-
extension: ext,
|
|
1168
|
-
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
|
|
1169
|
-
},
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
|
1173
|
-
const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
1174
|
-
if (stat.size > MAX_TEXT_FILE_SIZE) {
|
|
1175
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)`);
|
|
1176
|
-
}
|
|
1177
|
-
// Read text file with line limit (bounded to prevent DoS)
|
|
1178
|
-
const MAX_LINES_LIMIT = 10000;
|
|
1179
|
-
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
|
|
1180
|
-
const content = await fs.readFile(resolvedPath, 'utf-8');
|
|
1181
|
-
const allLines = content.split('\n');
|
|
1182
|
-
const truncatedContent = allLines.length > maxLines;
|
|
1183
|
-
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
|
1184
|
-
return {
|
|
1185
|
-
success: true,
|
|
1186
|
-
data: {
|
|
1187
|
-
path: filePath,
|
|
1188
|
-
content: displayContent,
|
|
1189
|
-
size: stat.size,
|
|
1190
|
-
totalLines: allLines.length,
|
|
1191
|
-
truncated: truncatedContent,
|
|
1192
|
-
extension: ext,
|
|
1193
|
-
},
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
catch (err) {
|
|
1197
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
|
|
1198
|
-
}
|
|
1199
|
-
});
|
|
1200
|
-
// Serve raw file content (for images/binary files)
|
|
1201
|
-
this.app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
|
1202
|
-
const { id } = req.params;
|
|
1203
|
-
const { path: filePath } = req.query;
|
|
1204
|
-
const session = this.sessions.get(id);
|
|
1205
|
-
if (!session) {
|
|
1206
|
-
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
if (!filePath) {
|
|
1210
|
-
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
|
1214
|
-
const fullPath = resolve(session.workingDir, filePath);
|
|
1215
|
-
let resolvedPath;
|
|
1216
|
-
try {
|
|
1217
|
-
resolvedPath = realpathSync(fullPath);
|
|
1218
|
-
}
|
|
1219
|
-
catch {
|
|
1220
|
-
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
const relativePath = relative(session.workingDir, resolvedPath);
|
|
1224
|
-
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
1225
|
-
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
|
|
1226
|
-
return;
|
|
1227
|
-
}
|
|
1228
|
-
try {
|
|
1229
|
-
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
|
1230
|
-
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
|
|
1231
|
-
const stat = await fs.stat(resolvedPath);
|
|
1232
|
-
if (stat.size > MAX_RAW_FILE_SIZE) {
|
|
1233
|
-
reply
|
|
1234
|
-
.code(400)
|
|
1235
|
-
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`));
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
1239
|
-
const mimeTypes = {
|
|
1240
|
-
png: 'image/png',
|
|
1241
|
-
jpg: 'image/jpeg',
|
|
1242
|
-
jpeg: 'image/jpeg',
|
|
1243
|
-
gif: 'image/gif',
|
|
1244
|
-
webp: 'image/webp',
|
|
1245
|
-
svg: 'image/svg+xml',
|
|
1246
|
-
ico: 'image/x-icon',
|
|
1247
|
-
bmp: 'image/bmp',
|
|
1248
|
-
mp4: 'video/mp4',
|
|
1249
|
-
webm: 'video/webm',
|
|
1250
|
-
mov: 'video/quicktime',
|
|
1251
|
-
mp3: 'audio/mpeg',
|
|
1252
|
-
wav: 'audio/wav',
|
|
1253
|
-
ogg: 'audio/ogg',
|
|
1254
|
-
pdf: 'application/pdf',
|
|
1255
|
-
json: 'application/json',
|
|
1256
|
-
};
|
|
1257
|
-
const content = await fs.readFile(resolvedPath);
|
|
1258
|
-
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
1259
|
-
reply.send(content);
|
|
1260
|
-
}
|
|
1261
|
-
catch (err) {
|
|
1262
|
-
reply
|
|
1263
|
-
.code(500)
|
|
1264
|
-
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
1265
|
-
}
|
|
1266
|
-
});
|
|
1267
|
-
// Stream file content via tail -f (SSE endpoint)
|
|
1268
|
-
this.app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
|
1269
|
-
const { id } = req.params;
|
|
1270
|
-
const { path: filePath, lines } = req.query;
|
|
1271
|
-
const session = this.sessions.get(id);
|
|
1272
|
-
if (!session) {
|
|
1273
|
-
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
if (!filePath) {
|
|
1277
|
-
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
// Set up SSE headers
|
|
1281
|
-
reply.raw.writeHead(200, {
|
|
1282
|
-
'Content-Type': 'text/event-stream',
|
|
1283
|
-
'Cache-Control': 'no-cache',
|
|
1284
|
-
Connection: 'keep-alive',
|
|
1285
|
-
'X-Accel-Buffering': 'no',
|
|
1286
|
-
});
|
|
1287
|
-
// Track stream for cleanup
|
|
1288
|
-
const streamRef = {};
|
|
1289
|
-
// Create the file stream
|
|
1290
|
-
const result = await fileStreamManager.createStream({
|
|
1291
|
-
sessionId: id,
|
|
1292
|
-
filePath,
|
|
1293
|
-
workingDir: session.workingDir,
|
|
1294
|
-
lines: lines ? parseInt(lines, 10) : undefined,
|
|
1295
|
-
onData: (data) => {
|
|
1296
|
-
// Send data as SSE event
|
|
1297
|
-
reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
|
|
1298
|
-
},
|
|
1299
|
-
onEnd: () => {
|
|
1300
|
-
reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
|
|
1301
|
-
reply.raw.end();
|
|
1302
|
-
},
|
|
1303
|
-
onError: (error) => {
|
|
1304
|
-
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
|
|
1305
|
-
},
|
|
1306
|
-
});
|
|
1307
|
-
if (!result.success) {
|
|
1308
|
-
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
|
|
1309
|
-
reply.raw.end();
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
streamRef.id = result.streamId;
|
|
1313
|
-
// Notify client of successful connection
|
|
1314
|
-
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
|
|
1315
|
-
// Handle client disconnect
|
|
1316
|
-
req.raw.on('close', () => {
|
|
1317
|
-
if (streamRef.id) {
|
|
1318
|
-
fileStreamManager.closeStream(streamRef.id);
|
|
1319
|
-
}
|
|
1320
|
-
});
|
|
1321
|
-
});
|
|
1322
|
-
// Close a file stream
|
|
1323
|
-
this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
|
1324
|
-
const { id, streamId } = req.params;
|
|
1325
|
-
const session = this.sessions.get(id);
|
|
1326
|
-
if (!session) {
|
|
1327
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1328
|
-
}
|
|
1329
|
-
const closed = fileStreamManager.closeStream(streamId);
|
|
1330
|
-
return { success: closed };
|
|
1331
|
-
});
|
|
1332
|
-
// Configure Ralph (Ralph Wiggum) settings
|
|
1333
|
-
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
|
1334
|
-
const { id } = req.params;
|
|
1335
|
-
const ralphResult = RalphConfigSchema.safeParse(req.body);
|
|
1336
|
-
if (!ralphResult.success) {
|
|
1337
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
1338
|
-
}
|
|
1339
|
-
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data;
|
|
1340
|
-
const session = this.sessions.get(id);
|
|
1341
|
-
if (!session) {
|
|
1342
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1343
|
-
}
|
|
1344
|
-
// Ralph tracker is not supported for opencode sessions
|
|
1345
|
-
if (session.mode === 'opencode') {
|
|
1346
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
|
|
1347
|
-
}
|
|
1348
|
-
// Handle reset first (before other config)
|
|
1349
|
-
if (reset) {
|
|
1350
|
-
if (reset === 'full') {
|
|
1351
|
-
session.ralphTracker.fullReset();
|
|
1352
|
-
}
|
|
1353
|
-
else {
|
|
1354
|
-
session.ralphTracker.reset();
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
// Configure auto-enable behavior
|
|
1358
|
-
if (disableAutoEnable !== undefined) {
|
|
1359
|
-
if (disableAutoEnable) {
|
|
1360
|
-
session.ralphTracker.disableAutoEnable();
|
|
1361
|
-
}
|
|
1362
|
-
else {
|
|
1363
|
-
session.ralphTracker.enableAutoEnable();
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
// Enable/disable the tracker
|
|
1367
|
-
if (enabled !== undefined) {
|
|
1368
|
-
if (enabled) {
|
|
1369
|
-
session.ralphTracker.enable();
|
|
1370
|
-
// Allow re-enabling on restart if user explicitly enabled
|
|
1371
|
-
session.ralphTracker.enableAutoEnable();
|
|
1372
|
-
}
|
|
1373
|
-
else {
|
|
1374
|
-
session.ralphTracker.disable();
|
|
1375
|
-
// Prevent re-enabling on restart when user explicitly disabled
|
|
1376
|
-
session.ralphTracker.disableAutoEnable();
|
|
1377
|
-
}
|
|
1378
|
-
// Persist Ralph enabled state
|
|
1379
|
-
this.mux.updateRalphEnabled(id, enabled);
|
|
1380
|
-
}
|
|
1381
|
-
// Configure the Ralph tracker
|
|
1382
|
-
if (completionPhrase !== undefined) {
|
|
1383
|
-
// Start loop with completion phrase to set it up for watching
|
|
1384
|
-
if (completionPhrase) {
|
|
1385
|
-
session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
if (maxIterations !== undefined) {
|
|
1389
|
-
session.ralphTracker.setMaxIterations(maxIterations || null);
|
|
1390
|
-
}
|
|
1391
|
-
// Persist and broadcast the update
|
|
1392
|
-
this.persistSessionState(session);
|
|
1393
|
-
this.broadcast('session:ralphLoopUpdate', {
|
|
1394
|
-
sessionId: id,
|
|
1395
|
-
state: session.ralphLoopState,
|
|
1396
|
-
});
|
|
1397
|
-
return { success: true };
|
|
1398
|
-
});
|
|
1399
|
-
// Reset circuit breaker for Ralph tracker
|
|
1400
|
-
this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
|
1401
|
-
const { id } = req.params;
|
|
1402
|
-
const session = this.sessions.get(id);
|
|
1403
|
-
if (!session) {
|
|
1404
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1405
|
-
}
|
|
1406
|
-
session.ralphTracker.resetCircuitBreaker();
|
|
1407
|
-
return { success: true };
|
|
1408
|
-
});
|
|
1409
|
-
// Get Ralph status block and circuit breaker state
|
|
1410
|
-
this.app.get('/api/sessions/:id/ralph-status', async (req) => {
|
|
1411
|
-
const { id } = req.params;
|
|
1412
|
-
const session = this.sessions.get(id);
|
|
1413
|
-
if (!session) {
|
|
1414
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1415
|
-
}
|
|
1416
|
-
return {
|
|
1417
|
-
success: true,
|
|
1418
|
-
data: {
|
|
1419
|
-
lastStatusBlock: session.ralphTracker.lastStatusBlock,
|
|
1420
|
-
circuitBreaker: session.ralphTracker.circuitBreakerStatus,
|
|
1421
|
-
cumulativeStats: session.ralphTracker.cumulativeStats,
|
|
1422
|
-
exitGateMet: session.ralphTracker.exitGateMet,
|
|
1423
|
-
},
|
|
1424
|
-
};
|
|
1425
|
-
});
|
|
1426
|
-
// Generate @fix_plan.md content from todos
|
|
1427
|
-
this.app.get('/api/sessions/:id/fix-plan', async (req) => {
|
|
1428
|
-
const { id } = req.params;
|
|
1429
|
-
const session = this.sessions.get(id);
|
|
1430
|
-
if (!session) {
|
|
1431
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1432
|
-
}
|
|
1433
|
-
const content = session.ralphTracker.generateFixPlanMarkdown();
|
|
1434
|
-
return {
|
|
1435
|
-
success: true,
|
|
1436
|
-
data: {
|
|
1437
|
-
content,
|
|
1438
|
-
todoCount: session.ralphTracker.todos.length,
|
|
1439
|
-
},
|
|
1440
|
-
};
|
|
1441
|
-
});
|
|
1442
|
-
// Import todos from @fix_plan.md content
|
|
1443
|
-
this.app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
|
1444
|
-
const { id } = req.params;
|
|
1445
|
-
const importResult = FixPlanImportSchema.safeParse(req.body);
|
|
1446
|
-
if (!importResult.success) {
|
|
1447
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
1448
|
-
}
|
|
1449
|
-
const { content } = importResult.data;
|
|
1450
|
-
const session = this.sessions.get(id);
|
|
1451
|
-
if (!session) {
|
|
1452
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1453
|
-
}
|
|
1454
|
-
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
|
1455
|
-
this.persistSessionState(session);
|
|
1456
|
-
return {
|
|
1457
|
-
success: true,
|
|
1458
|
-
data: {
|
|
1459
|
-
importedCount,
|
|
1460
|
-
todos: session.ralphTracker.todos,
|
|
1461
|
-
},
|
|
1462
|
-
};
|
|
1463
|
-
});
|
|
1464
|
-
// Write @fix_plan.md to session's working directory
|
|
1465
|
-
this.app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
|
1466
|
-
const { id } = req.params;
|
|
1467
|
-
const session = this.sessions.get(id);
|
|
1468
|
-
if (!session) {
|
|
1469
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1470
|
-
}
|
|
1471
|
-
const workingDir = session.workingDir;
|
|
1472
|
-
if (!workingDir) {
|
|
1473
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
|
1474
|
-
}
|
|
1475
|
-
const content = session.ralphTracker.generateFixPlanMarkdown();
|
|
1476
|
-
const filePath = join(workingDir, '@fix_plan.md');
|
|
1477
|
-
try {
|
|
1478
|
-
await fs.writeFile(filePath, content, 'utf-8');
|
|
1479
|
-
return {
|
|
1480
|
-
success: true,
|
|
1481
|
-
data: {
|
|
1482
|
-
filePath,
|
|
1483
|
-
todoCount: session.ralphTracker.todos.length,
|
|
1484
|
-
},
|
|
1485
|
-
};
|
|
1486
|
-
}
|
|
1487
|
-
catch (error) {
|
|
1488
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
// Read @fix_plan.md from session's working directory and import
|
|
1492
|
-
this.app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
|
1493
|
-
const { id } = req.params;
|
|
1494
|
-
const session = this.sessions.get(id);
|
|
1495
|
-
if (!session) {
|
|
1496
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1497
|
-
}
|
|
1498
|
-
const workingDir = session.workingDir;
|
|
1499
|
-
if (!workingDir) {
|
|
1500
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
|
1501
|
-
}
|
|
1502
|
-
const filePath = join(workingDir, '@fix_plan.md');
|
|
1503
|
-
try {
|
|
1504
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
1505
|
-
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
|
1506
|
-
this.persistSessionState(session);
|
|
1507
|
-
return {
|
|
1508
|
-
success: true,
|
|
1509
|
-
data: {
|
|
1510
|
-
filePath,
|
|
1511
|
-
importedCount,
|
|
1512
|
-
todos: session.ralphTracker.todos,
|
|
1513
|
-
},
|
|
1514
|
-
};
|
|
1515
|
-
}
|
|
1516
|
-
catch (error) {
|
|
1517
|
-
if (error.code === 'ENOENT') {
|
|
1518
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
|
1519
|
-
}
|
|
1520
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
|
|
1521
|
-
}
|
|
1522
|
-
});
|
|
1523
|
-
// Write Ralph prompt to file in session's working directory
|
|
1524
|
-
// This avoids mux input escaping issues with long multi-line prompts
|
|
1525
|
-
this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
|
1526
|
-
const { id } = req.params;
|
|
1527
|
-
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
|
1528
|
-
if (!promptResult.success) {
|
|
1529
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
1530
|
-
}
|
|
1531
|
-
const { content } = promptResult.data;
|
|
1532
|
-
const session = this.sessions.get(id);
|
|
1533
|
-
if (!session) {
|
|
1534
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1535
|
-
}
|
|
1536
|
-
const workingDir = session.workingDir;
|
|
1537
|
-
if (!workingDir) {
|
|
1538
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
|
1539
|
-
}
|
|
1540
|
-
const filePath = join(workingDir, '@ralph_prompt.md');
|
|
1541
|
-
try {
|
|
1542
|
-
await fs.writeFile(filePath, content, 'utf-8');
|
|
1543
|
-
return {
|
|
1544
|
-
success: true,
|
|
1545
|
-
data: {
|
|
1546
|
-
filePath,
|
|
1547
|
-
contentLength: content.length,
|
|
1548
|
-
},
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
catch (error) {
|
|
1552
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
|
|
1553
|
-
}
|
|
1554
|
-
});
|
|
1555
|
-
// Run prompt in session
|
|
1556
|
-
this.app.post('/api/sessions/:id/run', async (req) => {
|
|
1557
|
-
const { id } = req.params;
|
|
1558
|
-
const result = RunPromptSchema.safeParse(req.body);
|
|
1559
|
-
if (!result.success) {
|
|
1560
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
1561
|
-
}
|
|
1562
|
-
const { prompt } = result.data;
|
|
1563
|
-
const session = this.sessions.get(id);
|
|
1564
|
-
if (!session) {
|
|
1565
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1566
|
-
}
|
|
1567
|
-
if (session.isBusy()) {
|
|
1568
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
1569
|
-
}
|
|
1570
|
-
// Run async, don't wait
|
|
1571
|
-
session.runPrompt(prompt).catch((err) => {
|
|
1572
|
-
this.broadcast('session:error', { id, error: err.message });
|
|
1573
|
-
});
|
|
1574
|
-
this.broadcast('session:running', { id, prompt });
|
|
1575
|
-
return { success: true };
|
|
1576
|
-
});
|
|
1577
|
-
// Start interactive Claude session (persists even if browser disconnects)
|
|
1578
|
-
this.app.post('/api/sessions/:id/interactive', async (req) => {
|
|
1579
|
-
const { id } = req.params;
|
|
1580
|
-
const session = this.sessions.get(id);
|
|
1581
|
-
if (!session) {
|
|
1582
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1583
|
-
}
|
|
1584
|
-
if (session.isBusy()) {
|
|
1585
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
1586
|
-
}
|
|
1587
|
-
try {
|
|
1588
|
-
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
|
1589
|
-
// Ralph tracker is not supported for opencode sessions
|
|
1590
|
-
if (session.mode !== 'opencode' &&
|
|
1591
|
-
this.store.getConfig().ralphEnabled &&
|
|
1592
|
-
!session.ralphTracker.autoEnableDisabled) {
|
|
1593
|
-
autoConfigureRalph(session, session.workingDir, () => { });
|
|
1594
|
-
if (!session.ralphTracker.enabled) {
|
|
1595
|
-
session.ralphTracker.enable();
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
await session.startInteractive();
|
|
1599
|
-
getLifecycleLog().log({
|
|
1600
|
-
event: 'started',
|
|
1601
|
-
sessionId: id,
|
|
1602
|
-
name: session.name,
|
|
1603
|
-
mode: session.mode,
|
|
1604
|
-
});
|
|
1605
|
-
this.broadcast('session:interactive', { id });
|
|
1606
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
1607
|
-
return { success: true };
|
|
1608
|
-
}
|
|
1609
|
-
catch (err) {
|
|
1610
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
1611
|
-
}
|
|
1612
|
-
});
|
|
1613
|
-
// Start a plain shell session (no Claude)
|
|
1614
|
-
this.app.post('/api/sessions/:id/shell', async (req) => {
|
|
1615
|
-
const { id } = req.params;
|
|
1616
|
-
const session = this.sessions.get(id);
|
|
1617
|
-
if (!session) {
|
|
1618
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1619
|
-
}
|
|
1620
|
-
if (session.isBusy()) {
|
|
1621
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
1622
|
-
}
|
|
1623
|
-
try {
|
|
1624
|
-
await session.startShell();
|
|
1625
|
-
getLifecycleLog().log({
|
|
1626
|
-
event: 'started',
|
|
1627
|
-
sessionId: id,
|
|
1628
|
-
name: session.name,
|
|
1629
|
-
mode: 'shell',
|
|
1630
|
-
});
|
|
1631
|
-
this.broadcast('session:interactive', { id, mode: 'shell' });
|
|
1632
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
1633
|
-
return { success: true };
|
|
1634
|
-
}
|
|
1635
|
-
catch (err) {
|
|
1636
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
1637
|
-
}
|
|
1638
|
-
});
|
|
1639
|
-
// Send input to interactive session
|
|
1640
|
-
// useMux: true uses writeViaMux which is more reliable for programmatic input
|
|
1641
|
-
this.app.post('/api/sessions/:id/input', async (req) => {
|
|
1642
|
-
const { id } = req.params;
|
|
1643
|
-
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
|
1644
|
-
if (!result.success) {
|
|
1645
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
1646
|
-
}
|
|
1647
|
-
const { input, useMux } = result.data;
|
|
1648
|
-
const session = this.sessions.get(id);
|
|
1649
|
-
if (!session) {
|
|
1650
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1651
|
-
}
|
|
1652
|
-
const inputStr = String(input);
|
|
1653
|
-
if (inputStr.length > MAX_INPUT_LENGTH) {
|
|
1654
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
|
1655
|
-
}
|
|
1656
|
-
// Write input to PTY. Direct write is synchronous; writeViaMux
|
|
1657
|
-
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
|
1658
|
-
if (useMux) {
|
|
1659
|
-
// Fire-and-forget: don't block HTTP response on tmux child process.
|
|
1660
|
-
// Fallback to direct write on failure.
|
|
1661
|
-
session
|
|
1662
|
-
.writeViaMux(inputStr)
|
|
1663
|
-
.then((ok) => {
|
|
1664
|
-
if (!ok) {
|
|
1665
|
-
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
|
1666
|
-
session.write(inputStr);
|
|
1667
|
-
}
|
|
1668
|
-
})
|
|
1669
|
-
.catch(() => {
|
|
1670
|
-
session.write(inputStr);
|
|
1671
|
-
});
|
|
1672
|
-
}
|
|
1673
|
-
else {
|
|
1674
|
-
session.write(inputStr);
|
|
1675
|
-
}
|
|
1676
|
-
return { success: true };
|
|
1677
|
-
});
|
|
1678
|
-
// Resize session terminal
|
|
1679
|
-
this.app.post('/api/sessions/:id/resize', async (req) => {
|
|
1680
|
-
const { id } = req.params;
|
|
1681
|
-
const result = ResizeSchema.safeParse(req.body);
|
|
1682
|
-
if (!result.success) {
|
|
1683
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
1684
|
-
}
|
|
1685
|
-
const { cols, rows } = result.data;
|
|
1686
|
-
const session = this.sessions.get(id);
|
|
1687
|
-
if (!session) {
|
|
1688
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1689
|
-
}
|
|
1690
|
-
// Note: Zod already validates that cols and rows are positive integers within bounds
|
|
1691
|
-
if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
|
|
1692
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`);
|
|
1693
|
-
}
|
|
1694
|
-
session.resize(cols, rows);
|
|
1695
|
-
return { success: true };
|
|
1696
|
-
});
|
|
1697
|
-
// Get session terminal buffer (for reconnecting)
|
|
1698
|
-
// Query params:
|
|
1699
|
-
// tail=<bytes> - Only return last N bytes (faster initial load)
|
|
1700
|
-
this.app.get('/api/sessions/:id/terminal', async (req) => {
|
|
1701
|
-
const { id } = req.params;
|
|
1702
|
-
const query = req.query;
|
|
1703
|
-
const session = this.sessions.get(id);
|
|
1704
|
-
if (!session) {
|
|
1705
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1706
|
-
}
|
|
1707
|
-
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
|
1708
|
-
const fullSize = session.terminalBufferLength;
|
|
1709
|
-
let truncated = false;
|
|
1710
|
-
let cleanBuffer;
|
|
1711
|
-
if (tailBytes > 0 && fullSize > tailBytes) {
|
|
1712
|
-
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
|
1713
|
-
// Banner is near the top and gets discarded by tail anyway.
|
|
1714
|
-
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
|
|
1715
|
-
truncated = true;
|
|
1716
|
-
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
|
1717
|
-
// and start from there. This prevents xterm.js from parsing a partial escape
|
|
1718
|
-
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
|
1719
|
-
const firstNewline = cleanBuffer.indexOf('\n');
|
|
1720
|
-
if (firstNewline > 0 && firstNewline < 4096) {
|
|
1721
|
-
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
else {
|
|
1725
|
-
// Full buffer: clean junk before actual Claude content
|
|
1726
|
-
cleanBuffer = session.terminalBuffer;
|
|
1727
|
-
// Find where Claude banner starts (has color codes before "Claude")
|
|
1728
|
-
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
|
1729
|
-
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
|
1730
|
-
let lineStart = claudeMatch.index;
|
|
1731
|
-
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
|
1732
|
-
lineStart--;
|
|
1733
|
-
}
|
|
1734
|
-
cleanBuffer = cleanBuffer.slice(lineStart);
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
|
1738
|
-
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
|
1739
|
-
return {
|
|
1740
|
-
terminalBuffer: cleanBuffer,
|
|
1741
|
-
status: session.status,
|
|
1742
|
-
fullSize,
|
|
1743
|
-
truncated,
|
|
1744
|
-
};
|
|
1745
|
-
});
|
|
1746
|
-
// ============ Respawn Controller Endpoints ============
|
|
1747
|
-
// Get respawn status for a session
|
|
1748
|
-
this.app.get('/api/sessions/:id/respawn', async (req) => {
|
|
1749
|
-
const { id } = req.params;
|
|
1750
|
-
const controller = this.respawnControllers.get(id);
|
|
1751
|
-
if (!controller) {
|
|
1752
|
-
return { enabled: false, status: null };
|
|
1753
|
-
}
|
|
1754
|
-
return {
|
|
1755
|
-
enabled: true,
|
|
1756
|
-
...controller.getStatus(),
|
|
1757
|
-
};
|
|
1758
|
-
});
|
|
1759
|
-
// Get respawn config (from running controller or pre-saved)
|
|
1760
|
-
this.app.get('/api/sessions/:id/respawn/config', async (req) => {
|
|
1761
|
-
const { id } = req.params;
|
|
1762
|
-
const controller = this.respawnControllers.get(id);
|
|
1763
|
-
if (controller) {
|
|
1764
|
-
return { success: true, config: controller.getConfig(), active: true };
|
|
1765
|
-
}
|
|
1766
|
-
// Return pre-saved config from mux-sessions.json
|
|
1767
|
-
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
|
1768
|
-
if (preConfig) {
|
|
1769
|
-
return { success: true, config: preConfig, active: false };
|
|
1770
|
-
}
|
|
1771
|
-
return { success: true, config: null, active: false };
|
|
1772
|
-
});
|
|
1773
|
-
// Start respawn controller for a session
|
|
1774
|
-
this.app.post('/api/sessions/:id/respawn/start', async (req) => {
|
|
1775
|
-
const { id } = req.params;
|
|
1776
|
-
let body;
|
|
1777
|
-
if (req.body) {
|
|
1778
|
-
const result = RespawnConfigSchema.safeParse(req.body);
|
|
1779
|
-
if (!result.success) {
|
|
1780
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
|
|
1781
|
-
}
|
|
1782
|
-
body = result.data;
|
|
1783
|
-
}
|
|
1784
|
-
const session = this.sessions.get(id);
|
|
1785
|
-
if (!session) {
|
|
1786
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1787
|
-
}
|
|
1788
|
-
// Respawn is not supported for opencode sessions
|
|
1789
|
-
if (session.mode === 'opencode') {
|
|
1790
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
|
1791
|
-
}
|
|
1792
|
-
// Create or get existing controller
|
|
1793
|
-
let controller = this.respawnControllers.get(id);
|
|
1794
|
-
if (!controller) {
|
|
1795
|
-
// Merge request body with pre-saved config from mux-sessions.json
|
|
1796
|
-
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
|
1797
|
-
const config = body || preConfig ? { ...preConfig, ...body } : undefined;
|
|
1798
|
-
controller = new RespawnController(session, config);
|
|
1799
|
-
this.respawnControllers.set(id, controller);
|
|
1800
|
-
this.setupRespawnListeners(id, controller);
|
|
1801
|
-
}
|
|
1802
|
-
else if (body) {
|
|
1803
|
-
controller.updateConfig(body);
|
|
1804
|
-
}
|
|
1805
|
-
controller.start();
|
|
1806
|
-
// Persist respawn config to mux session and state.json
|
|
1807
|
-
this.saveRespawnConfig(id, controller.getConfig());
|
|
1808
|
-
this.persistSessionState(session);
|
|
1809
|
-
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
|
1810
|
-
return { success: true, status: controller.getStatus() };
|
|
1811
|
-
});
|
|
1812
|
-
// Stop respawn controller for a session
|
|
1813
|
-
this.app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
|
1814
|
-
const { id } = req.params;
|
|
1815
|
-
const controller = this.respawnControllers.get(id);
|
|
1816
|
-
if (!controller) {
|
|
1817
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
|
|
1818
|
-
}
|
|
1819
|
-
controller.stop();
|
|
1820
|
-
// Remove controller from map so persistSessionState doesn't save respawnEnabled: true
|
|
1821
|
-
this.respawnControllers.delete(id);
|
|
1822
|
-
// Clear any timed respawn
|
|
1823
|
-
const timerInfo = this.respawnTimers.get(id);
|
|
1824
|
-
if (timerInfo) {
|
|
1825
|
-
clearTimeout(timerInfo.timer);
|
|
1826
|
-
this.respawnTimers.delete(id);
|
|
1827
|
-
}
|
|
1828
|
-
// Clear persisted respawn config
|
|
1829
|
-
this.mux.clearRespawnConfig(id);
|
|
1830
|
-
// Update state.json (respawnConfig removed)
|
|
1831
|
-
const session = this.sessions.get(id);
|
|
1832
|
-
if (session) {
|
|
1833
|
-
this.persistSessionState(session);
|
|
1834
|
-
}
|
|
1835
|
-
this.broadcast('respawn:stopped', { sessionId: id });
|
|
1836
|
-
return { success: true };
|
|
1837
|
-
});
|
|
1838
|
-
// Update respawn configuration (works with or without running controller)
|
|
1839
|
-
this.app.put('/api/sessions/:id/respawn/config', async (req) => {
|
|
1840
|
-
const { id } = req.params;
|
|
1841
|
-
// Validate respawn config to prevent arbitrary field injection
|
|
1842
|
-
const parseResult = RespawnConfigSchema.safeParse(req.body);
|
|
1843
|
-
if (!parseResult.success) {
|
|
1844
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
|
|
1845
|
-
}
|
|
1846
|
-
const config = parseResult.data;
|
|
1847
|
-
const session = this.sessions.get(id);
|
|
1848
|
-
if (!session) {
|
|
1849
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1850
|
-
}
|
|
1851
|
-
const controller = this.respawnControllers.get(id);
|
|
1852
|
-
if (controller) {
|
|
1853
|
-
// Update running controller
|
|
1854
|
-
controller.updateConfig(config);
|
|
1855
|
-
this.saveRespawnConfig(id, controller.getConfig());
|
|
1856
|
-
this.persistSessionState(session);
|
|
1857
|
-
this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
|
1858
|
-
return { success: true, config: controller.getConfig() };
|
|
1859
|
-
}
|
|
1860
|
-
// No controller running - save as pre-config for when respawn starts
|
|
1861
|
-
const existing = this.mux.getSession(id);
|
|
1862
|
-
const currentConfig = existing?.respawnConfig;
|
|
1863
|
-
const merged = {
|
|
1864
|
-
enabled: config.enabled ?? currentConfig?.enabled ?? false,
|
|
1865
|
-
idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000,
|
|
1866
|
-
updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md',
|
|
1867
|
-
interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000,
|
|
1868
|
-
sendClear: config.sendClear ?? currentConfig?.sendClear ?? true,
|
|
1869
|
-
sendInit: config.sendInit ?? currentConfig?.sendInit ?? true,
|
|
1870
|
-
kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt,
|
|
1871
|
-
autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
|
|
1872
|
-
autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
|
|
1873
|
-
aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true,
|
|
1874
|
-
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101',
|
|
1875
|
-
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000,
|
|
1876
|
-
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
|
|
1877
|
-
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
|
|
1878
|
-
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
|
|
1879
|
-
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101',
|
|
1880
|
-
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000,
|
|
1881
|
-
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
|
|
1882
|
-
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
|
1883
|
-
durationMinutes: currentConfig?.durationMinutes,
|
|
1884
|
-
};
|
|
1885
|
-
this.mux.updateRespawnConfig(id, merged);
|
|
1886
|
-
this.persistSessionState(session);
|
|
1887
|
-
this.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
|
1888
|
-
return { success: true, config: merged };
|
|
1889
|
-
});
|
|
1890
|
-
// Start interactive session WITH respawn enabled
|
|
1891
|
-
this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
|
1892
|
-
const { id } = req.params;
|
|
1893
|
-
const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true, data: {} };
|
|
1894
|
-
if (!irResult.success) {
|
|
1895
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
1896
|
-
}
|
|
1897
|
-
const body = irResult.data;
|
|
1898
|
-
const session = this.sessions.get(id);
|
|
1899
|
-
if (!session) {
|
|
1900
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1901
|
-
}
|
|
1902
|
-
if (session.isBusy()) {
|
|
1903
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
1904
|
-
}
|
|
1905
|
-
// Respawn is not supported for opencode sessions
|
|
1906
|
-
if (session.mode === 'opencode') {
|
|
1907
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
|
1908
|
-
}
|
|
1909
|
-
try {
|
|
1910
|
-
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
|
1911
|
-
if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
|
|
1912
|
-
autoConfigureRalph(session, session.workingDir, () => { });
|
|
1913
|
-
if (!session.ralphTracker.enabled) {
|
|
1914
|
-
session.ralphTracker.enable();
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
// Start interactive session
|
|
1918
|
-
await session.startInteractive();
|
|
1919
|
-
getLifecycleLog().log({
|
|
1920
|
-
event: 'started',
|
|
1921
|
-
sessionId: id,
|
|
1922
|
-
name: session.name,
|
|
1923
|
-
mode: session.mode,
|
|
1924
|
-
reason: 'interactive_respawn',
|
|
1925
|
-
});
|
|
1926
|
-
this.broadcast('session:interactive', { id });
|
|
1927
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
1928
|
-
// Create and start respawn controller
|
|
1929
|
-
const controller = new RespawnController(session, body?.respawnConfig);
|
|
1930
|
-
this.respawnControllers.set(id, controller);
|
|
1931
|
-
this.setupRespawnListeners(id, controller);
|
|
1932
|
-
controller.start();
|
|
1933
|
-
// Set up timed stop if duration specified
|
|
1934
|
-
if (body?.durationMinutes && body.durationMinutes > 0) {
|
|
1935
|
-
this.setupTimedRespawn(id, body.durationMinutes);
|
|
1936
|
-
}
|
|
1937
|
-
// Persist full session state with respawn config
|
|
1938
|
-
this.persistSessionState(session);
|
|
1939
|
-
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
|
1940
|
-
return {
|
|
1941
|
-
success: true,
|
|
1942
|
-
data: {
|
|
1943
|
-
message: 'Interactive session with respawn started',
|
|
1944
|
-
respawnStatus: controller.getStatus(),
|
|
1945
|
-
},
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
catch (err) {
|
|
1949
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
1950
|
-
}
|
|
1951
|
-
});
|
|
1952
|
-
// Enable respawn on an EXISTING interactive session
|
|
1953
|
-
this.app.post('/api/sessions/:id/respawn/enable', async (req) => {
|
|
1954
|
-
const { id } = req.params;
|
|
1955
|
-
const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true, data: {} };
|
|
1956
|
-
if (!reResult.success) {
|
|
1957
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
1958
|
-
}
|
|
1959
|
-
const body = reResult.data;
|
|
1960
|
-
const session = this.sessions.get(id);
|
|
1961
|
-
if (!session) {
|
|
1962
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
1963
|
-
}
|
|
1964
|
-
// Respawn is not supported for opencode sessions
|
|
1965
|
-
if (session.mode === 'opencode') {
|
|
1966
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
|
1967
|
-
}
|
|
1968
|
-
// Check if session is running (has a PID)
|
|
1969
|
-
if (!session.pid) {
|
|
1970
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
|
|
1971
|
-
}
|
|
1972
|
-
// Stop existing controller if any
|
|
1973
|
-
const existingController = this.respawnControllers.get(id);
|
|
1974
|
-
if (existingController) {
|
|
1975
|
-
existingController.stop();
|
|
1976
|
-
}
|
|
1977
|
-
// Create and start new respawn controller (merge with pre-saved config)
|
|
1978
|
-
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
|
1979
|
-
const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined;
|
|
1980
|
-
const controller = new RespawnController(session, config);
|
|
1981
|
-
this.respawnControllers.set(id, controller);
|
|
1982
|
-
this.setupRespawnListeners(id, controller);
|
|
1983
|
-
controller.start();
|
|
1984
|
-
// Set up timed stop if duration specified
|
|
1985
|
-
if (body?.durationMinutes && body.durationMinutes > 0) {
|
|
1986
|
-
this.setupTimedRespawn(id, body.durationMinutes);
|
|
1987
|
-
}
|
|
1988
|
-
// Persist respawn config to mux session and state.json
|
|
1989
|
-
this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
|
|
1990
|
-
this.persistSessionState(session);
|
|
1991
|
-
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
|
1992
|
-
return {
|
|
1993
|
-
success: true,
|
|
1994
|
-
message: 'Respawn enabled on existing session',
|
|
1995
|
-
respawnStatus: controller.getStatus(),
|
|
1996
|
-
};
|
|
1997
|
-
});
|
|
1998
|
-
// Set auto-clear on a session
|
|
1999
|
-
this.app.post('/api/sessions/:id/auto-clear', async (req) => {
|
|
2000
|
-
const { id } = req.params;
|
|
2001
|
-
const acResult = AutoClearSchema.safeParse(req.body);
|
|
2002
|
-
if (!acResult.success) {
|
|
2003
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2004
|
-
}
|
|
2005
|
-
const body = acResult.data;
|
|
2006
|
-
const session = this.sessions.get(id);
|
|
2007
|
-
if (!session) {
|
|
2008
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
2009
|
-
}
|
|
2010
|
-
session.setAutoClear(body.enabled, body.threshold);
|
|
2011
|
-
this.persistSessionState(session);
|
|
2012
|
-
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
|
2013
|
-
return {
|
|
2014
|
-
success: true,
|
|
2015
|
-
data: {
|
|
2016
|
-
autoClear: {
|
|
2017
|
-
enabled: session.autoClearEnabled,
|
|
2018
|
-
threshold: session.autoClearThreshold,
|
|
2019
|
-
},
|
|
2020
|
-
},
|
|
2021
|
-
};
|
|
2022
|
-
});
|
|
2023
|
-
// Set auto-compact on a session
|
|
2024
|
-
this.app.post('/api/sessions/:id/auto-compact', async (req) => {
|
|
2025
|
-
const { id } = req.params;
|
|
2026
|
-
const compactResult = AutoCompactSchema.safeParse(req.body);
|
|
2027
|
-
if (!compactResult.success) {
|
|
2028
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2029
|
-
}
|
|
2030
|
-
const body = compactResult.data;
|
|
2031
|
-
const session = this.sessions.get(id);
|
|
2032
|
-
if (!session) {
|
|
2033
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
2034
|
-
}
|
|
2035
|
-
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
|
2036
|
-
this.persistSessionState(session);
|
|
2037
|
-
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
|
2038
|
-
return {
|
|
2039
|
-
success: true,
|
|
2040
|
-
data: {
|
|
2041
|
-
autoCompact: {
|
|
2042
|
-
enabled: session.autoCompactEnabled,
|
|
2043
|
-
threshold: session.autoCompactThreshold,
|
|
2044
|
-
prompt: session.autoCompactPrompt,
|
|
2045
|
-
},
|
|
2046
|
-
},
|
|
2047
|
-
};
|
|
2048
|
-
});
|
|
2049
|
-
// Toggle image watcher for a session
|
|
2050
|
-
this.app.post('/api/sessions/:id/image-watcher', async (req) => {
|
|
2051
|
-
const { id } = req.params;
|
|
2052
|
-
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
|
2053
|
-
if (!iwResult.success) {
|
|
2054
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2055
|
-
}
|
|
2056
|
-
const body = iwResult.data;
|
|
2057
|
-
const session = this.sessions.get(id);
|
|
2058
|
-
if (!session) {
|
|
2059
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
2060
|
-
}
|
|
2061
|
-
if (body.enabled) {
|
|
2062
|
-
imageWatcher.watchSession(session.id, session.workingDir);
|
|
2063
|
-
}
|
|
2064
|
-
else {
|
|
2065
|
-
imageWatcher.unwatchSession(session.id);
|
|
2066
|
-
}
|
|
2067
|
-
// Store state on session for persistence
|
|
2068
|
-
session.imageWatcherEnabled = body.enabled;
|
|
2069
|
-
this.persistSessionState(session);
|
|
2070
|
-
return {
|
|
2071
|
-
success: true,
|
|
2072
|
-
data: {
|
|
2073
|
-
imageWatcherEnabled: body.enabled,
|
|
2074
|
-
},
|
|
2075
|
-
};
|
|
2076
|
-
});
|
|
2077
|
-
// Toggle flicker filter for a session
|
|
2078
|
-
this.app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
|
2079
|
-
const { id } = req.params;
|
|
2080
|
-
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
|
2081
|
-
if (!ffResult.success) {
|
|
2082
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2083
|
-
}
|
|
2084
|
-
const body = ffResult.data;
|
|
2085
|
-
const session = this.sessions.get(id);
|
|
2086
|
-
if (!session) {
|
|
2087
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
2088
|
-
}
|
|
2089
|
-
session.flickerFilterEnabled = body.enabled;
|
|
2090
|
-
this.persistSessionState(session);
|
|
2091
|
-
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
|
2092
|
-
return {
|
|
2093
|
-
success: true,
|
|
2094
|
-
data: {
|
|
2095
|
-
flickerFilterEnabled: body.enabled,
|
|
2096
|
-
},
|
|
2097
|
-
};
|
|
2098
|
-
});
|
|
2099
|
-
// Quick run (create session, run prompt, return result, then cleanup)
|
|
2100
|
-
this.app.post('/api/run', async (req) => {
|
|
2101
|
-
// Prevent unbounded session creation
|
|
2102
|
-
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
2103
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
|
2104
|
-
}
|
|
2105
|
-
const qrResult = QuickRunSchema.safeParse(req.body);
|
|
2106
|
-
if (!qrResult.success) {
|
|
2107
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2108
|
-
}
|
|
2109
|
-
const { prompt, workingDir } = qrResult.data;
|
|
2110
|
-
if (!prompt.trim()) {
|
|
2111
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
|
2112
|
-
}
|
|
2113
|
-
const dir = workingDir || process.cwd();
|
|
2114
|
-
// Validate workingDir exists and is a directory
|
|
2115
|
-
if (workingDir) {
|
|
2116
|
-
try {
|
|
2117
|
-
const stat = statSync(dir);
|
|
2118
|
-
if (!stat.isDirectory()) {
|
|
2119
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
catch {
|
|
2123
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
const session = new Session({ workingDir: dir });
|
|
2127
|
-
this.sessions.set(session.id, session);
|
|
2128
|
-
this.store.incrementSessionsCreated();
|
|
2129
|
-
this.persistSessionState(session);
|
|
2130
|
-
await this.setupSessionListeners(session);
|
|
2131
|
-
getLifecycleLog().log({
|
|
2132
|
-
event: 'created',
|
|
2133
|
-
sessionId: session.id,
|
|
2134
|
-
name: session.name,
|
|
2135
|
-
reason: 'run_prompt',
|
|
2136
|
-
});
|
|
2137
|
-
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
|
2138
|
-
try {
|
|
2139
|
-
const result = await session.runPrompt(prompt);
|
|
2140
|
-
// Clean up session after completion to prevent memory leak
|
|
2141
|
-
await this.cleanupSession(session.id, true, 'run_prompt_complete');
|
|
2142
|
-
return { success: true, sessionId: session.id, ...result };
|
|
2143
|
-
}
|
|
2144
|
-
catch (err) {
|
|
2145
|
-
// Clean up session on error too
|
|
2146
|
-
await this.cleanupSession(session.id, true, 'run_prompt_error');
|
|
2147
|
-
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
|
|
2148
|
-
}
|
|
2149
|
-
});
|
|
2150
|
-
// Scheduled runs
|
|
2151
|
-
this.app.get('/api/scheduled', async () => {
|
|
2152
|
-
return Array.from(this.scheduledRuns.values());
|
|
2153
|
-
});
|
|
2154
|
-
this.app.post('/api/scheduled', async (req) => {
|
|
2155
|
-
const srResult = ScheduledRunSchema.safeParse(req.body);
|
|
2156
|
-
if (!srResult.success) {
|
|
2157
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2158
|
-
}
|
|
2159
|
-
const { prompt, workingDir, durationMinutes } = srResult.data;
|
|
2160
|
-
// Validate workingDir exists and is a directory
|
|
2161
|
-
if (workingDir) {
|
|
2162
|
-
try {
|
|
2163
|
-
const stat = statSync(workingDir);
|
|
2164
|
-
if (!stat.isDirectory()) {
|
|
2165
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
catch {
|
|
2169
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
2170
|
-
}
|
|
2171
|
-
}
|
|
2172
|
-
const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
|
2173
|
-
return { success: true, run };
|
|
2174
|
-
});
|
|
2175
|
-
this.app.delete('/api/scheduled/:id', async (req) => {
|
|
2176
|
-
const { id } = req.params;
|
|
2177
|
-
const run = this.scheduledRuns.get(id);
|
|
2178
|
-
if (!run) {
|
|
2179
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
|
2180
|
-
}
|
|
2181
|
-
await this.stopScheduledRun(id);
|
|
2182
|
-
return { success: true };
|
|
2183
|
-
});
|
|
2184
|
-
this.app.get('/api/scheduled/:id', async (req) => {
|
|
2185
|
-
const { id } = req.params;
|
|
2186
|
-
const run = this.scheduledRuns.get(id);
|
|
2187
|
-
if (!run) {
|
|
2188
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
|
2189
|
-
}
|
|
2190
|
-
return run;
|
|
2191
|
-
});
|
|
2192
|
-
// Case management
|
|
2193
|
-
const casesDir = join(homedir(), 'codeman-cases');
|
|
2194
|
-
this.app.get('/api/cases', async () => {
|
|
2195
|
-
const cases = [];
|
|
2196
|
-
// Get cases from casesDir
|
|
2197
|
-
try {
|
|
2198
|
-
const entries = await fs.readdir(casesDir, { withFileTypes: true });
|
|
2199
|
-
for (const e of entries) {
|
|
2200
|
-
if (e.isDirectory()) {
|
|
2201
|
-
cases.push({
|
|
2202
|
-
name: e.name,
|
|
2203
|
-
path: join(casesDir, e.name),
|
|
2204
|
-
hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')),
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
}
|
|
2209
|
-
catch {
|
|
2210
|
-
// casesDir may not exist yet
|
|
2211
|
-
}
|
|
2212
|
-
// Get linked cases
|
|
2213
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
2214
|
-
try {
|
|
2215
|
-
const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
2216
|
-
for (const [name, path] of Object.entries(linkedCases)) {
|
|
2217
|
-
// Only add if not already in cases (avoid duplicates) and path exists
|
|
2218
|
-
if (!cases.some((c) => c.name === name) && existsSync(path)) {
|
|
2219
|
-
cases.push({
|
|
2220
|
-
name,
|
|
2221
|
-
path,
|
|
2222
|
-
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
|
2223
|
-
});
|
|
2224
|
-
}
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
catch (err) {
|
|
2228
|
-
if (err.code !== 'ENOENT') {
|
|
2229
|
-
console.warn('[Server] Failed to read linked cases:', err);
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
return cases;
|
|
2233
|
-
});
|
|
2234
|
-
this.app.post('/api/cases', async (req) => {
|
|
2235
|
-
const result = CreateCaseSchema.safeParse(req.body);
|
|
2236
|
-
if (!result.success) {
|
|
2237
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
2238
|
-
}
|
|
2239
|
-
const { name, description } = result.data;
|
|
2240
|
-
const casePath = join(casesDir, name);
|
|
2241
|
-
// Security: Path traversal protection - use relative path check
|
|
2242
|
-
const resolvedPath = resolve(casePath);
|
|
2243
|
-
const resolvedBase = resolve(casesDir);
|
|
2244
|
-
const relPath = relative(resolvedBase, resolvedPath);
|
|
2245
|
-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
|
2246
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
2247
|
-
}
|
|
2248
|
-
if (existsSync(casePath)) {
|
|
2249
|
-
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
|
2250
|
-
}
|
|
2251
|
-
try {
|
|
2252
|
-
mkdirSync(casePath, { recursive: true });
|
|
2253
|
-
mkdirSync(join(casePath, 'src'), { recursive: true });
|
|
2254
|
-
// Read settings to get custom template path
|
|
2255
|
-
const templatePath = await this.getDefaultClaudeMdPath();
|
|
2256
|
-
const claudeMd = generateClaudeMd(name, description || '', templatePath);
|
|
2257
|
-
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
|
2258
|
-
// Write .claude/settings.local.json with hooks for desktop notifications
|
|
2259
|
-
await writeHooksConfig(casePath);
|
|
2260
|
-
this.broadcast('case:created', { name, path: casePath });
|
|
2261
|
-
return { success: true, data: { case: { name, path: casePath } } };
|
|
2262
|
-
}
|
|
2263
|
-
catch (err) {
|
|
2264
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
2265
|
-
}
|
|
2266
|
-
});
|
|
2267
|
-
// Link an existing folder as a case
|
|
2268
|
-
this.app.post('/api/cases/link', async (req) => {
|
|
2269
|
-
const lcResult = LinkCaseSchema.safeParse(req.body);
|
|
2270
|
-
if (!lcResult.success) {
|
|
2271
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2272
|
-
}
|
|
2273
|
-
const { name, path: folderPath } = lcResult.data;
|
|
2274
|
-
// Expand ~ to home directory
|
|
2275
|
-
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
|
2276
|
-
// Validate the folder exists
|
|
2277
|
-
if (!existsSync(expandedPath)) {
|
|
2278
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
|
|
2279
|
-
}
|
|
2280
|
-
// Check if case name already exists in casesDir
|
|
2281
|
-
const casePath = join(casesDir, name);
|
|
2282
|
-
if (existsSync(casePath)) {
|
|
2283
|
-
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
|
2284
|
-
}
|
|
2285
|
-
// Load existing linked cases
|
|
2286
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
2287
|
-
let linkedCases = {};
|
|
2288
|
-
try {
|
|
2289
|
-
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
2290
|
-
}
|
|
2291
|
-
catch (err) {
|
|
2292
|
-
if (err.code !== 'ENOENT') {
|
|
2293
|
-
console.warn('[Server] Failed to read linked cases:', err);
|
|
2294
|
-
}
|
|
2295
|
-
}
|
|
2296
|
-
// Check if name is already linked
|
|
2297
|
-
if (linkedCases[name]) {
|
|
2298
|
-
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}`);
|
|
2299
|
-
}
|
|
2300
|
-
// Save the linked case
|
|
2301
|
-
linkedCases[name] = expandedPath;
|
|
2302
|
-
try {
|
|
2303
|
-
const codemanDir = join(homedir(), '.codeman');
|
|
2304
|
-
if (!existsSync(codemanDir)) {
|
|
2305
|
-
mkdirSync(codemanDir, { recursive: true });
|
|
2306
|
-
}
|
|
2307
|
-
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
|
|
2308
|
-
this.broadcast('case:linked', { name, path: expandedPath });
|
|
2309
|
-
return { success: true, data: { case: { name, path: expandedPath } } };
|
|
2310
|
-
}
|
|
2311
|
-
catch (err) {
|
|
2312
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
2313
|
-
}
|
|
2314
|
-
});
|
|
2315
|
-
this.app.get('/api/cases/:name', async (req) => {
|
|
2316
|
-
const { name } = req.params;
|
|
2317
|
-
// First check linked cases
|
|
2318
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
2319
|
-
try {
|
|
2320
|
-
const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
2321
|
-
if (linkedCases[name]) {
|
|
2322
|
-
const linkedPath = linkedCases[name];
|
|
2323
|
-
return {
|
|
2324
|
-
name,
|
|
2325
|
-
path: linkedPath,
|
|
2326
|
-
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
|
|
2327
|
-
linked: true,
|
|
2328
|
-
};
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
catch {
|
|
2332
|
-
// ENOENT or parse errors - fall through to casesDir check
|
|
2333
|
-
}
|
|
2334
|
-
// Then check casesDir
|
|
2335
|
-
const casePath = join(casesDir, name);
|
|
2336
|
-
if (!existsSync(casePath)) {
|
|
2337
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
|
2338
|
-
}
|
|
2339
|
-
return {
|
|
2340
|
-
name,
|
|
2341
|
-
path: casePath,
|
|
2342
|
-
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
|
2343
|
-
};
|
|
2344
|
-
});
|
|
2345
|
-
// Read @fix_plan.md from a case directory (for wizard to detect existing plans)
|
|
2346
|
-
this.app.get('/api/cases/:name/fix-plan', async (req) => {
|
|
2347
|
-
const { name } = req.params;
|
|
2348
|
-
// Get case path (check linked cases first, then casesDir)
|
|
2349
|
-
let casePath = null;
|
|
2350
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
2351
|
-
try {
|
|
2352
|
-
const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
2353
|
-
if (linkedCases[name]) {
|
|
2354
|
-
casePath = linkedCases[name];
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
catch {
|
|
2358
|
-
// ENOENT or parse errors - fall through to casesDir
|
|
2359
|
-
}
|
|
2360
|
-
if (!casePath) {
|
|
2361
|
-
casePath = join(casesDir, name);
|
|
2362
|
-
}
|
|
2363
|
-
const fixPlanPath = join(casePath, '@fix_plan.md');
|
|
2364
|
-
if (!existsSync(fixPlanPath)) {
|
|
2365
|
-
return { success: true, exists: false, content: null, todos: [] };
|
|
2366
|
-
}
|
|
2367
|
-
try {
|
|
2368
|
-
const content = await fs.readFile(fixPlanPath, 'utf-8');
|
|
2369
|
-
// Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown)
|
|
2370
|
-
const todos = [];
|
|
2371
|
-
const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/;
|
|
2372
|
-
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i;
|
|
2373
|
-
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
|
|
2374
|
-
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
|
|
2375
|
-
const completedHeaderPattern = /^##\s*Completed/i;
|
|
2376
|
-
let currentPriority = null;
|
|
2377
|
-
let inCompletedSection = false;
|
|
2378
|
-
for (const line of content.split('\n')) {
|
|
2379
|
-
const trimmed = line.trim();
|
|
2380
|
-
if (p0HeaderPattern.test(trimmed)) {
|
|
2381
|
-
currentPriority = 'P0';
|
|
2382
|
-
inCompletedSection = false;
|
|
2383
|
-
continue;
|
|
2384
|
-
}
|
|
2385
|
-
if (p1HeaderPattern.test(trimmed)) {
|
|
2386
|
-
currentPriority = 'P1';
|
|
2387
|
-
inCompletedSection = false;
|
|
2388
|
-
continue;
|
|
2389
|
-
}
|
|
2390
|
-
if (p2HeaderPattern.test(trimmed)) {
|
|
2391
|
-
currentPriority = 'P2';
|
|
2392
|
-
inCompletedSection = false;
|
|
2393
|
-
continue;
|
|
2394
|
-
}
|
|
2395
|
-
if (completedHeaderPattern.test(trimmed)) {
|
|
2396
|
-
inCompletedSection = true;
|
|
2397
|
-
continue;
|
|
2398
|
-
}
|
|
2399
|
-
const match = trimmed.match(todoPattern);
|
|
2400
|
-
if (match) {
|
|
2401
|
-
const [, checkboxState, taskContent] = match;
|
|
2402
|
-
let status;
|
|
2403
|
-
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
|
|
2404
|
-
status = 'completed';
|
|
2405
|
-
}
|
|
2406
|
-
else if (checkboxState === '-') {
|
|
2407
|
-
status = 'in_progress';
|
|
2408
|
-
}
|
|
2409
|
-
else {
|
|
2410
|
-
status = 'pending';
|
|
2411
|
-
}
|
|
2412
|
-
todos.push({
|
|
2413
|
-
content: taskContent.trim(),
|
|
2414
|
-
status,
|
|
2415
|
-
priority: inCompletedSection ? null : currentPriority,
|
|
2416
|
-
});
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
// Calculate stats in a single pass for better performance
|
|
2420
|
-
let pending = 0, inProgress = 0, completed = 0;
|
|
2421
|
-
for (const t of todos) {
|
|
2422
|
-
if (t.status === 'pending')
|
|
2423
|
-
pending++;
|
|
2424
|
-
else if (t.status === 'in_progress')
|
|
2425
|
-
inProgress++;
|
|
2426
|
-
else if (t.status === 'completed')
|
|
2427
|
-
completed++;
|
|
2428
|
-
}
|
|
2429
|
-
const stats = { total: todos.length, pending, inProgress, completed };
|
|
2430
|
-
return {
|
|
2431
|
-
success: true,
|
|
2432
|
-
exists: true,
|
|
2433
|
-
content,
|
|
2434
|
-
todos,
|
|
2435
|
-
stats,
|
|
2436
|
-
};
|
|
2437
|
-
}
|
|
2438
|
-
catch (err) {
|
|
2439
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`);
|
|
2440
|
-
}
|
|
2441
|
-
});
|
|
2442
|
-
// Quick Start: Create case (if needed) and start interactive session in one click
|
|
2443
|
-
this.app.post('/api/quick-start', async (req) => {
|
|
2444
|
-
// Prevent unbounded session creation
|
|
2445
|
-
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
2446
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
|
|
2447
|
-
}
|
|
2448
|
-
const result = QuickStartSchema.safeParse(req.body);
|
|
2449
|
-
if (!result.success) {
|
|
2450
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
2451
|
-
}
|
|
2452
|
-
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
|
2453
|
-
// Check OpenCode availability if requested
|
|
2454
|
-
if (mode === 'opencode') {
|
|
2455
|
-
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
|
2456
|
-
if (!isOpenCodeAvailable()) {
|
|
2457
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
const casePath = join(casesDir, caseName);
|
|
2461
|
-
// Security: Path traversal protection - use relative path check
|
|
2462
|
-
const resolvedPath = resolve(casePath);
|
|
2463
|
-
const resolvedBase = resolve(casesDir);
|
|
2464
|
-
const relPath = relative(resolvedBase, resolvedPath);
|
|
2465
|
-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
|
2466
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
2467
|
-
}
|
|
2468
|
-
// Create case folder and CLAUDE.md if it doesn't exist
|
|
2469
|
-
if (!existsSync(casePath)) {
|
|
2470
|
-
try {
|
|
2471
|
-
mkdirSync(casePath, { recursive: true });
|
|
2472
|
-
mkdirSync(join(casePath, 'src'), { recursive: true });
|
|
2473
|
-
// Read settings to get custom template path
|
|
2474
|
-
const templatePath = await this.getDefaultClaudeMdPath();
|
|
2475
|
-
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
|
2476
|
-
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
|
2477
|
-
// Write .claude/settings.local.json with hooks for desktop notifications
|
|
2478
|
-
// (Claude-specific — OpenCode uses its own plugin system)
|
|
2479
|
-
if (mode !== 'opencode') {
|
|
2480
|
-
await writeHooksConfig(casePath);
|
|
2481
|
-
}
|
|
2482
|
-
this.broadcast('case:created', { name: caseName, path: casePath });
|
|
2483
|
-
}
|
|
2484
|
-
catch (err) {
|
|
2485
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
// Create a new session with the case as working directory
|
|
2489
|
-
// Apply global Nice priority config and model config from settings
|
|
2490
|
-
const niceConfig = await this.getGlobalNiceConfig();
|
|
2491
|
-
const qsModelConfig = await this.getModelConfig();
|
|
2492
|
-
const qsModel = mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
|
2493
|
-
const qsClaudeModeConfig = await this.getClaudeModeConfig();
|
|
2494
|
-
const session = new Session({
|
|
2495
|
-
workingDir: casePath,
|
|
2496
|
-
mux: this.mux,
|
|
2497
|
-
useMux: true,
|
|
2498
|
-
mode: mode,
|
|
2499
|
-
niceConfig: niceConfig,
|
|
2500
|
-
model: qsModel,
|
|
2501
|
-
claudeMode: qsClaudeModeConfig.claudeMode,
|
|
2502
|
-
allowedTools: qsClaudeModeConfig.allowedTools,
|
|
2503
|
-
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
|
2504
|
-
});
|
|
2505
|
-
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
|
2506
|
-
// so the initial state already has the phrase configured (only if globally enabled)
|
|
2507
|
-
if (mode === 'claude' && this.store.getConfig().ralphEnabled) {
|
|
2508
|
-
autoConfigureRalph(session, casePath, () => { }); // no broadcast yet
|
|
2509
|
-
if (!session.ralphTracker.enabled) {
|
|
2510
|
-
session.ralphTracker.enable();
|
|
2511
|
-
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
2512
|
-
}
|
|
2513
|
-
}
|
|
2514
|
-
this.sessions.set(session.id, session);
|
|
2515
|
-
this.store.incrementSessionsCreated();
|
|
2516
|
-
this.persistSessionState(session);
|
|
2517
|
-
await this.setupSessionListeners(session);
|
|
2518
|
-
getLifecycleLog().log({
|
|
2519
|
-
event: 'created',
|
|
2520
|
-
sessionId: session.id,
|
|
2521
|
-
name: session.name,
|
|
2522
|
-
reason: 'quick_start',
|
|
2523
|
-
});
|
|
2524
|
-
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
|
2525
|
-
// Start in the appropriate mode
|
|
2526
|
-
try {
|
|
2527
|
-
if (mode === 'shell') {
|
|
2528
|
-
await session.startShell();
|
|
2529
|
-
getLifecycleLog().log({
|
|
2530
|
-
event: 'started',
|
|
2531
|
-
sessionId: session.id,
|
|
2532
|
-
name: session.name,
|
|
2533
|
-
mode: 'shell',
|
|
2534
|
-
});
|
|
2535
|
-
this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
|
2536
|
-
}
|
|
2537
|
-
else {
|
|
2538
|
-
// Both 'claude' and 'opencode' modes use startInteractive()
|
|
2539
|
-
await session.startInteractive();
|
|
2540
|
-
getLifecycleLog().log({
|
|
2541
|
-
event: 'started',
|
|
2542
|
-
sessionId: session.id,
|
|
2543
|
-
name: session.name,
|
|
2544
|
-
mode,
|
|
2545
|
-
});
|
|
2546
|
-
this.broadcast('session:interactive', { id: session.id, mode });
|
|
2547
|
-
}
|
|
2548
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
2549
|
-
// Save lastUsedCase to settings for TUI/web sync
|
|
2550
|
-
try {
|
|
2551
|
-
const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
|
|
2552
|
-
let settings = {};
|
|
2553
|
-
try {
|
|
2554
|
-
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
|
2555
|
-
}
|
|
2556
|
-
catch (err) {
|
|
2557
|
-
if (err.code !== 'ENOENT')
|
|
2558
|
-
throw err;
|
|
2559
|
-
}
|
|
2560
|
-
settings.lastUsedCase = caseName;
|
|
2561
|
-
const dir = dirname(settingsFilePath);
|
|
2562
|
-
if (!existsSync(dir)) {
|
|
2563
|
-
mkdirSync(dir, { recursive: true });
|
|
2564
|
-
}
|
|
2565
|
-
// Use async write to avoid blocking event loop
|
|
2566
|
-
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
2567
|
-
// Non-critical but log for debugging
|
|
2568
|
-
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
|
2569
|
-
});
|
|
2570
|
-
}
|
|
2571
|
-
catch (err) {
|
|
2572
|
-
// Non-critical but log for debugging
|
|
2573
|
-
console.warn('[Server] Failed to prepare settings update:', err);
|
|
2574
|
-
}
|
|
2575
|
-
return {
|
|
2576
|
-
success: true,
|
|
2577
|
-
sessionId: session.id,
|
|
2578
|
-
casePath,
|
|
2579
|
-
caseName,
|
|
2580
|
-
};
|
|
2581
|
-
}
|
|
2582
|
-
catch (err) {
|
|
2583
|
-
// Clean up session on error to prevent orphaned resources
|
|
2584
|
-
await this.cleanupSession(session.id, true, 'quick_start_error');
|
|
2585
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
2586
|
-
}
|
|
2587
|
-
});
|
|
2588
|
-
// ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ==========
|
|
2589
|
-
this.app.post('/api/ralph-loop/start', async (req) => {
|
|
2590
|
-
// Prevent unbounded session creation
|
|
2591
|
-
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
2592
|
-
return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
|
|
2593
|
-
}
|
|
2594
|
-
const rlResult = RalphLoopStartSchema.safeParse(req.body);
|
|
2595
|
-
if (!rlResult.success) {
|
|
2596
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
|
|
2597
|
-
}
|
|
2598
|
-
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
|
|
2599
|
-
const casePath = join(casesDir, caseName);
|
|
2600
|
-
// Security: Path traversal protection
|
|
2601
|
-
const rlResolvedPath = resolve(casePath);
|
|
2602
|
-
const rlResolvedBase = resolve(casesDir);
|
|
2603
|
-
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
|
2604
|
-
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
|
2605
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
2606
|
-
}
|
|
2607
|
-
// Create case folder if it doesn't exist (reuse quick-start logic)
|
|
2608
|
-
if (!existsSync(casePath)) {
|
|
2609
|
-
try {
|
|
2610
|
-
mkdirSync(casePath, { recursive: true });
|
|
2611
|
-
mkdirSync(join(casePath, 'src'), { recursive: true });
|
|
2612
|
-
const templatePath = await this.getDefaultClaudeMdPath();
|
|
2613
|
-
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
|
2614
|
-
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
|
2615
|
-
await writeHooksConfig(casePath);
|
|
2616
|
-
this.broadcast('case:created', { name: caseName, path: casePath });
|
|
2617
|
-
}
|
|
2618
|
-
catch (err) {
|
|
2619
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
// Create session
|
|
2623
|
-
const niceConfig = await this.getGlobalNiceConfig();
|
|
2624
|
-
const rlModelConfig = await this.getModelConfig();
|
|
2625
|
-
const rlClaudeModeConfig = await this.getClaudeModeConfig();
|
|
2626
|
-
const session = new Session({
|
|
2627
|
-
workingDir: casePath,
|
|
2628
|
-
mux: this.mux,
|
|
2629
|
-
useMux: true,
|
|
2630
|
-
mode: 'claude',
|
|
2631
|
-
niceConfig,
|
|
2632
|
-
model: rlModelConfig?.defaultModel,
|
|
2633
|
-
claudeMode: rlClaudeModeConfig.claudeMode,
|
|
2634
|
-
allowedTools: rlClaudeModeConfig.allowedTools,
|
|
2635
|
-
});
|
|
2636
|
-
// Configure Ralph tracker
|
|
2637
|
-
autoConfigureRalph(session, casePath, () => { });
|
|
2638
|
-
if (!session.ralphTracker.enabled) {
|
|
2639
|
-
session.ralphTracker.enable();
|
|
2640
|
-
session.ralphTracker.enableAutoEnable();
|
|
2641
|
-
}
|
|
2642
|
-
session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined);
|
|
2643
|
-
// Build fix_plan markdown from plan items if provided
|
|
2644
|
-
const enabledItems = planItems?.filter((i) => i.enabled) ?? [];
|
|
2645
|
-
let planContent = '';
|
|
2646
|
-
if (enabledItems.length > 0) {
|
|
2647
|
-
const p0 = enabledItems.filter((i) => i.priority === 'P0');
|
|
2648
|
-
const p1 = enabledItems.filter((i) => i.priority === 'P1');
|
|
2649
|
-
const p2 = enabledItems.filter((i) => i.priority === 'P2');
|
|
2650
|
-
const noPri = enabledItems.filter((i) => !i.priority);
|
|
2651
|
-
planContent = '# Implementation Plan\n\n';
|
|
2652
|
-
planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`;
|
|
2653
|
-
if (p0.length > 0) {
|
|
2654
|
-
planContent += '## Critical Path (P0)\n\n';
|
|
2655
|
-
p0.forEach((i) => {
|
|
2656
|
-
planContent += `- [ ] ${i.content}\n`;
|
|
2657
|
-
});
|
|
2658
|
-
planContent += '\n';
|
|
2659
|
-
}
|
|
2660
|
-
if (p1.length > 0) {
|
|
2661
|
-
planContent += '## Standard (P1)\n\n';
|
|
2662
|
-
p1.forEach((i) => {
|
|
2663
|
-
planContent += `- [ ] ${i.content}\n`;
|
|
2664
|
-
});
|
|
2665
|
-
planContent += '\n';
|
|
2666
|
-
}
|
|
2667
|
-
if (p2.length > 0) {
|
|
2668
|
-
planContent += '## Nice-to-Have (P2)\n\n';
|
|
2669
|
-
p2.forEach((i) => {
|
|
2670
|
-
planContent += `- [ ] ${i.content}\n`;
|
|
2671
|
-
});
|
|
2672
|
-
planContent += '\n';
|
|
2673
|
-
}
|
|
2674
|
-
if (noPri.length > 0) {
|
|
2675
|
-
planContent += '## Tasks\n\n';
|
|
2676
|
-
noPri.forEach((i) => {
|
|
2677
|
-
planContent += `- [ ] ${i.content}\n`;
|
|
2678
|
-
});
|
|
2679
|
-
planContent += '\n';
|
|
2680
|
-
}
|
|
2681
|
-
// Import into tracker and write to disk
|
|
2682
|
-
session.ralphTracker.importFixPlanMarkdown(planContent);
|
|
2683
|
-
const fixPlanPath = join(casePath, '@fix_plan.md');
|
|
2684
|
-
writeFileSync(fixPlanPath, planContent, 'utf-8');
|
|
2685
|
-
}
|
|
2686
|
-
// Build full prompt
|
|
2687
|
-
const hasPlan = enabledItems.length > 0;
|
|
2688
|
-
let fullPrompt = taskDescription + '\n\n---\n\n';
|
|
2689
|
-
if (hasPlan) {
|
|
2690
|
-
fullPrompt += '## Task Plan\n\n';
|
|
2691
|
-
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
|
|
2692
|
-
fullPrompt += '- Reference the plan at the start of each iteration\n';
|
|
2693
|
-
fullPrompt += '- Update task checkboxes as you complete items\n';
|
|
2694
|
-
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
|
|
2695
|
-
}
|
|
2696
|
-
fullPrompt += '## Iteration Protocol\n\n';
|
|
2697
|
-
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
|
|
2698
|
-
fullPrompt += '1. Check what work has already been done\n';
|
|
2699
|
-
fullPrompt += '2. Make incremental progress toward completion\n';
|
|
2700
|
-
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
|
|
2701
|
-
fullPrompt += '## Verification\n\n';
|
|
2702
|
-
fullPrompt += 'After each significant change:\n';
|
|
2703
|
-
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
|
|
2704
|
-
fullPrompt += '- Check for type/lint errors if applicable\n';
|
|
2705
|
-
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
|
|
2706
|
-
fullPrompt += '## Completion Criteria\n\n';
|
|
2707
|
-
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
|
|
2708
|
-
fullPrompt += '- All requirements from the task description are implemented\n';
|
|
2709
|
-
fullPrompt += '- All tests pass\n';
|
|
2710
|
-
fullPrompt += '- Changes are committed\n\n';
|
|
2711
|
-
fullPrompt += '## If Stuck\n\n';
|
|
2712
|
-
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
|
|
2713
|
-
fullPrompt += "1. Document what you've tried\n";
|
|
2714
|
-
fullPrompt += '2. Identify the specific blocker\n';
|
|
2715
|
-
fullPrompt += '3. Try an alternative approach\n';
|
|
2716
|
-
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
|
|
2717
|
-
// Write prompt to file
|
|
2718
|
-
const promptPath = join(casePath, '@ralph_prompt.md');
|
|
2719
|
-
writeFileSync(promptPath, fullPrompt, 'utf-8');
|
|
2720
|
-
// Register session
|
|
2721
|
-
this.sessions.set(session.id, session);
|
|
2722
|
-
this.store.incrementSessionsCreated();
|
|
2723
|
-
this.persistSessionState(session);
|
|
2724
|
-
await this.setupSessionListeners(session);
|
|
2725
|
-
getLifecycleLog().log({
|
|
2726
|
-
event: 'created',
|
|
2727
|
-
sessionId: session.id,
|
|
2728
|
-
name: session.name,
|
|
2729
|
-
reason: 'ralph_loop_start',
|
|
2730
|
-
});
|
|
2731
|
-
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
|
2732
|
-
// Start interactive mode
|
|
2733
|
-
try {
|
|
2734
|
-
await session.startInteractive();
|
|
2735
|
-
getLifecycleLog().log({
|
|
2736
|
-
event: 'started',
|
|
2737
|
-
sessionId: session.id,
|
|
2738
|
-
name: session.name,
|
|
2739
|
-
mode: 'claude',
|
|
2740
|
-
});
|
|
2741
|
-
this.broadcast('session:interactive', { id: session.id, mode: 'claude' });
|
|
2742
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
2743
|
-
}
|
|
2744
|
-
catch (err) {
|
|
2745
|
-
await this.cleanupSession(session.id, true, 'ralph_loop_start_error');
|
|
2746
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
2747
|
-
}
|
|
2748
|
-
// Enable respawn if requested
|
|
2749
|
-
if (enableRespawn) {
|
|
2750
|
-
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
|
|
2751
|
-
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${completionPhrase}</promise> when ALL tasks are complete.`;
|
|
2752
|
-
const controller = new RespawnController(session, {
|
|
2753
|
-
updatePrompt: ralphUpdatePrompt,
|
|
2754
|
-
sendClear: true,
|
|
2755
|
-
sendInit: true,
|
|
2756
|
-
kickstartPrompt: ralphKickstartPrompt,
|
|
2757
|
-
});
|
|
2758
|
-
this.respawnControllers.set(session.id, controller);
|
|
2759
|
-
this.setupRespawnListeners(session.id, controller);
|
|
2760
|
-
controller.start();
|
|
2761
|
-
this.saveRespawnConfig(session.id, controller.getConfig());
|
|
2762
|
-
this.persistSessionState(session);
|
|
2763
|
-
this.broadcast('respawn:started', {
|
|
2764
|
-
sessionId: session.id,
|
|
2765
|
-
status: controller.getStatus(),
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
// Save lastUsedCase
|
|
2769
|
-
try {
|
|
2770
|
-
const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
|
|
2771
|
-
let settings = {};
|
|
2772
|
-
try {
|
|
2773
|
-
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
|
2774
|
-
}
|
|
2775
|
-
catch {
|
|
2776
|
-
/* ignore */
|
|
2777
|
-
}
|
|
2778
|
-
settings.lastUsedCase = caseName;
|
|
2779
|
-
const dir = dirname(settingsFilePath);
|
|
2780
|
-
if (!existsSync(dir))
|
|
2781
|
-
mkdirSync(dir, { recursive: true });
|
|
2782
|
-
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => { });
|
|
2783
|
-
}
|
|
2784
|
-
catch {
|
|
2785
|
-
/* non-critical */
|
|
2786
|
-
}
|
|
2787
|
-
const sessionId = session.id;
|
|
2788
|
-
// Async: poll for CLI readiness, then send prompt
|
|
2789
|
-
setImmediate(() => {
|
|
2790
|
-
const pollReady = async () => {
|
|
2791
|
-
for (let attempt = 0; attempt < 60; attempt++) {
|
|
2792
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
2793
|
-
const s = this.sessions.get(sessionId);
|
|
2794
|
-
if (!s)
|
|
2795
|
-
return; // session was deleted
|
|
2796
|
-
// Check terminal output for prompt indicator
|
|
2797
|
-
const termBuf = s.getTerminalBuffer().slice(-2048);
|
|
2798
|
-
if (termBuf.includes('❯') || termBuf.includes('tokens')) {
|
|
2799
|
-
break;
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
// Small extra delay for CLI to settle
|
|
2803
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
2804
|
-
const s = this.sessions.get(sessionId);
|
|
2805
|
-
if (!s)
|
|
2806
|
-
return;
|
|
2807
|
-
try {
|
|
2808
|
-
await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r');
|
|
2809
|
-
}
|
|
2810
|
-
catch (err) {
|
|
2811
|
-
console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err));
|
|
2812
|
-
}
|
|
2813
|
-
};
|
|
2814
|
-
pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err));
|
|
2815
|
-
});
|
|
2816
|
-
return {
|
|
2817
|
-
success: true,
|
|
2818
|
-
data: { sessionId, caseName },
|
|
2819
|
-
};
|
|
2820
|
-
});
|
|
2821
|
-
this.app.post('/api/generate-plan', async (req) => {
|
|
2822
|
-
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
|
2823
|
-
if (!gpResult.success) {
|
|
2824
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
2825
|
-
}
|
|
2826
|
-
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
|
|
2827
|
-
// Build sophisticated prompt based on Ralph Wiggum methodology
|
|
2828
|
-
const detailConfig = {
|
|
2829
|
-
brief: { style: 'high-level milestones', testDepth: 'basic' },
|
|
2830
|
-
standard: { style: 'balanced implementation steps', testDepth: 'thorough' },
|
|
2831
|
-
detailed: {
|
|
2832
|
-
style: 'granular sub-tasks with full TDD coverage',
|
|
2833
|
-
testDepth: 'comprehensive',
|
|
2834
|
-
},
|
|
2835
|
-
};
|
|
2836
|
-
const levelConfig = detailConfig[detailLevel] || detailConfig.standard;
|
|
2837
|
-
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
|
|
2838
|
-
|
|
2839
|
-
## TASK TO IMPLEMENT
|
|
2840
|
-
${taskDescription}
|
|
2841
|
-
|
|
2842
|
-
## YOUR MISSION
|
|
2843
|
-
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
|
|
2844
|
-
Think deeply about:
|
|
2845
|
-
- What are ALL the components, modules, and features needed?
|
|
2846
|
-
- What could go wrong? Add defensive steps for error handling.
|
|
2847
|
-
- How will we verify each part works? Tests before implementation.
|
|
2848
|
-
- What edge cases need handling?
|
|
2849
|
-
- What's the logical order of dependencies?
|
|
2850
|
-
|
|
2851
|
-
## DETAIL LEVEL: ${detailLevel.toUpperCase()}
|
|
2852
|
-
Style: ${levelConfig.style}
|
|
2853
|
-
Generate as many steps as needed to properly cover the task - don't artificially limit yourself.
|
|
2854
|
-
For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity.
|
|
2855
|
-
|
|
2856
|
-
## PLAN STRUCTURE
|
|
2857
|
-
|
|
2858
|
-
Your plan MUST include these phases in order:
|
|
2859
|
-
|
|
2860
|
-
### Phase 1: Foundation & Setup
|
|
2861
|
-
- Project structure, dependencies, configuration
|
|
2862
|
-
- Database schemas, type definitions, interfaces
|
|
2863
|
-
|
|
2864
|
-
### Phase 2: Core Implementation (TDD Cycle)
|
|
2865
|
-
For EACH feature:
|
|
2866
|
-
1. Write failing tests first (unit tests)
|
|
2867
|
-
2. Implement the feature
|
|
2868
|
-
3. Run tests, debug until passing
|
|
2869
|
-
4. Refactor if needed
|
|
2870
|
-
|
|
2871
|
-
### Phase 3: Integration & Edge Cases
|
|
2872
|
-
- Integration tests for feature interactions
|
|
2873
|
-
- Edge case handling (errors, boundaries, invalid input)
|
|
2874
|
-
- Error messages and user feedback
|
|
2875
|
-
|
|
2876
|
-
### Phase 4: Verification & Hardening
|
|
2877
|
-
- Run full test suite
|
|
2878
|
-
- Fix any failing tests
|
|
2879
|
-
- Add missing test coverage
|
|
2880
|
-
- Final verification that ALL requirements are met
|
|
2881
|
-
|
|
2882
|
-
## OUTPUT FORMAT
|
|
2883
|
-
Return ONLY a JSON array. Each item MUST have:
|
|
2884
|
-
- id: unique identifier (e.g., "P0-001", "P1-002")
|
|
2885
|
-
- content: specific action (verb phrase, 15-120 chars, be descriptive!)
|
|
2886
|
-
- priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement)
|
|
2887
|
-
- verificationCriteria: HOW to verify this step is complete (required!)
|
|
2888
|
-
- tddPhase: "setup" | "test" | "impl" | "verify"
|
|
2889
|
-
- dependencies: array of task IDs this depends on (empty if none)
|
|
2890
|
-
|
|
2891
|
-
## EXAMPLE OUTPUT
|
|
2892
|
-
[
|
|
2893
|
-
{"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []},
|
|
2894
|
-
{"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]},
|
|
2895
|
-
{"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]},
|
|
2896
|
-
{"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]},
|
|
2897
|
-
{"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]},
|
|
2898
|
-
{"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]},
|
|
2899
|
-
{"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]},
|
|
2900
|
-
{"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]},
|
|
2901
|
-
{"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]}
|
|
2902
|
-
]
|
|
2903
|
-
|
|
2904
|
-
## CRITICAL RULES
|
|
2905
|
-
1. EVERY task MUST have verificationCriteria - this is non-negotiable!
|
|
2906
|
-
2. EVERY implementation step should have a corresponding test step BEFORE it
|
|
2907
|
-
3. Use tddPhase: "test" for writing tests, "impl" for implementation
|
|
2908
|
-
4. Dependencies must form a valid DAG - no cycles
|
|
2909
|
-
5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z"
|
|
2910
|
-
6. End with verification that ALL original requirements are met
|
|
2911
|
-
7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have
|
|
2912
|
-
|
|
2913
|
-
NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
2914
|
-
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
|
2915
|
-
const session = new Session({
|
|
2916
|
-
workingDir: process.cwd(),
|
|
2917
|
-
mux: this.mux,
|
|
2918
|
-
useMux: false, // No mux needed for one-shot
|
|
2919
|
-
mode: 'claude',
|
|
2920
|
-
});
|
|
2921
|
-
// Use configured model for plan generation, falling back to opus
|
|
2922
|
-
const planModelConfig = await this.getModelConfig();
|
|
2923
|
-
const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus';
|
|
2924
|
-
try {
|
|
2925
|
-
const { result, cost } = await session.runPrompt(prompt, { model: modelToUse });
|
|
2926
|
-
// Parse JSON from result
|
|
2927
|
-
const jsonMatch = result.match(/\[[\s\S]*\]/);
|
|
2928
|
-
if (!jsonMatch) {
|
|
2929
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found');
|
|
2930
|
-
}
|
|
2931
|
-
let items;
|
|
2932
|
-
try {
|
|
2933
|
-
const parsed = JSON.parse(jsonMatch[0]);
|
|
2934
|
-
if (!Array.isArray(parsed)) {
|
|
2935
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array');
|
|
2936
|
-
}
|
|
2937
|
-
// Validate and normalize items with enhanced fields
|
|
2938
|
-
items = parsed.map((item, idx) => {
|
|
2939
|
-
if (typeof item !== 'object' || item === null) {
|
|
2940
|
-
return {
|
|
2941
|
-
id: `task-${idx}`,
|
|
2942
|
-
content: `Step ${idx + 1}`,
|
|
2943
|
-
priority: null,
|
|
2944
|
-
verificationCriteria: 'Task completed successfully',
|
|
2945
|
-
status: 'pending',
|
|
2946
|
-
attempts: 0,
|
|
2947
|
-
version: 1,
|
|
2948
|
-
};
|
|
2949
|
-
}
|
|
2950
|
-
const obj = item;
|
|
2951
|
-
const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`;
|
|
2952
|
-
let priority = null;
|
|
2953
|
-
if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
|
|
2954
|
-
priority = obj.priority;
|
|
2955
|
-
}
|
|
2956
|
-
// Parse tddPhase
|
|
2957
|
-
let tddPhase;
|
|
2958
|
-
if (obj.tddPhase === 'setup' ||
|
|
2959
|
-
obj.tddPhase === 'test' ||
|
|
2960
|
-
obj.tddPhase === 'impl' ||
|
|
2961
|
-
obj.tddPhase === 'verify') {
|
|
2962
|
-
tddPhase = obj.tddPhase;
|
|
2963
|
-
}
|
|
2964
|
-
return {
|
|
2965
|
-
id: obj.id ? String(obj.id) : `task-${idx}`,
|
|
2966
|
-
content,
|
|
2967
|
-
priority,
|
|
2968
|
-
verificationCriteria: typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully',
|
|
2969
|
-
tddPhase,
|
|
2970
|
-
dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [],
|
|
2971
|
-
status: 'pending',
|
|
2972
|
-
attempts: 0,
|
|
2973
|
-
version: 1,
|
|
2974
|
-
};
|
|
2975
|
-
});
|
|
2976
|
-
// No artificial limit - let Claude generate what's needed
|
|
2977
|
-
}
|
|
2978
|
-
catch (parseErr) {
|
|
2979
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
|
|
2980
|
-
}
|
|
2981
|
-
return {
|
|
2982
|
-
success: true,
|
|
2983
|
-
data: { items, costUsd: cost },
|
|
2984
|
-
};
|
|
2985
|
-
}
|
|
2986
|
-
catch (err) {
|
|
2987
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err));
|
|
2988
|
-
}
|
|
2989
|
-
finally {
|
|
2990
|
-
// Clean up the temporary session
|
|
2991
|
-
try {
|
|
2992
|
-
await session.stop();
|
|
2993
|
-
}
|
|
2994
|
-
catch {
|
|
2995
|
-
// Ignore cleanup errors
|
|
2996
|
-
}
|
|
2997
|
-
}
|
|
2998
|
-
});
|
|
2999
|
-
// Generate detailed implementation plan using subagent orchestration
|
|
3000
|
-
// This spawns multiple specialist subagents in parallel for thorough analysis
|
|
3001
|
-
this.app.post('/api/generate-plan-detailed', async (req) => {
|
|
3002
|
-
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
|
3003
|
-
if (!gpdResult.success) {
|
|
3004
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
3005
|
-
}
|
|
3006
|
-
const { taskDescription, caseName } = gpdResult.data;
|
|
3007
|
-
// Determine output directory for saving wizard results
|
|
3008
|
-
let outputDir;
|
|
3009
|
-
if (caseName) {
|
|
3010
|
-
const casesDir = join(homedir(), 'codeman-cases');
|
|
3011
|
-
const casePath = join(casesDir, caseName);
|
|
3012
|
-
// Security: Path traversal protection - use relative path check
|
|
3013
|
-
const resolvedCase = resolve(casePath);
|
|
3014
|
-
const resolvedBase = resolve(casesDir);
|
|
3015
|
-
const relPath = relative(resolvedBase, resolvedCase);
|
|
3016
|
-
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
|
|
3017
|
-
outputDir = join(casePath, 'ralph-wizard');
|
|
3018
|
-
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
|
|
3019
|
-
// This prevents stale prompts from previous runs being shown when clicking on agents
|
|
3020
|
-
if (existsSync(outputDir)) {
|
|
3021
|
-
try {
|
|
3022
|
-
rmSync(outputDir, { recursive: true, force: true });
|
|
3023
|
-
console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`);
|
|
3024
|
-
}
|
|
3025
|
-
catch (err) {
|
|
3026
|
-
console.warn(`[API] Failed to clear ralph-wizard directory:`, err);
|
|
3027
|
-
}
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
}
|
|
3031
|
-
const detailedModelConfig = await this.getModelConfig();
|
|
3032
|
-
const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
|
3033
|
-
// Store orchestrator for potential cancellation via API (not on disconnect)
|
|
3034
|
-
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
|
3035
|
-
const orchestratorId = `plan-${Date.now()}`;
|
|
3036
|
-
this.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
|
3037
|
-
// Broadcast the orchestrator ID so frontend can cancel if needed
|
|
3038
|
-
this.broadcast('plan:started', { orchestratorId });
|
|
3039
|
-
// Track progress for SSE updates
|
|
3040
|
-
const progressUpdates = [];
|
|
3041
|
-
const onProgress = (phase, detail) => {
|
|
3042
|
-
const update = { phase, detail, timestamp: Date.now() };
|
|
3043
|
-
progressUpdates.push(update);
|
|
3044
|
-
// Broadcast progress to connected clients
|
|
3045
|
-
this.broadcast('plan:progress', update);
|
|
3046
|
-
};
|
|
3047
|
-
// Broadcast plan subagent events for UI visibility
|
|
3048
|
-
const onSubagent = (event) => {
|
|
3049
|
-
this.broadcast('plan:subagent', event);
|
|
3050
|
-
};
|
|
3051
|
-
try {
|
|
3052
|
-
const result = await orchestrator.generateDetailedPlan(taskDescription, onProgress, onSubagent);
|
|
3053
|
-
// Clean up orchestrator from active map
|
|
3054
|
-
this.activePlanOrchestrators.delete(orchestratorId);
|
|
3055
|
-
this.broadcast('plan:completed', { orchestratorId, success: result.success });
|
|
3056
|
-
if (!result.success) {
|
|
3057
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
|
3058
|
-
}
|
|
3059
|
-
return {
|
|
3060
|
-
success: true,
|
|
3061
|
-
data: {
|
|
3062
|
-
items: result.items,
|
|
3063
|
-
costUsd: result.costUsd,
|
|
3064
|
-
metadata: result.metadata,
|
|
3065
|
-
progressLog: progressUpdates,
|
|
3066
|
-
orchestratorId,
|
|
3067
|
-
},
|
|
3068
|
-
};
|
|
3069
|
-
}
|
|
3070
|
-
catch (err) {
|
|
3071
|
-
// Clean up on error too
|
|
3072
|
-
this.activePlanOrchestrators.delete(orchestratorId);
|
|
3073
|
-
this.broadcast('plan:completed', {
|
|
3074
|
-
orchestratorId,
|
|
3075
|
-
success: false,
|
|
3076
|
-
error: getErrorMessage(err),
|
|
3077
|
-
});
|
|
3078
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Detailed plan generation failed: ' + getErrorMessage(err));
|
|
3079
|
-
}
|
|
3080
|
-
});
|
|
3081
|
-
// Cancel active plan generation
|
|
3082
|
-
this.app.post('/api/cancel-plan-generation', async (req) => {
|
|
3083
|
-
const cpResult = CancelPlanSchema.safeParse(req.body);
|
|
3084
|
-
if (!cpResult.success) {
|
|
3085
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
3086
|
-
}
|
|
3087
|
-
const { orchestratorId } = cpResult.data;
|
|
3088
|
-
// If specific orchestrator ID provided, cancel just that one
|
|
3089
|
-
if (orchestratorId) {
|
|
3090
|
-
const orchestrator = this.activePlanOrchestrators.get(orchestratorId);
|
|
3091
|
-
if (!orchestrator) {
|
|
3092
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed');
|
|
3093
|
-
}
|
|
3094
|
-
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
|
3095
|
-
await orchestrator.cancel();
|
|
3096
|
-
this.activePlanOrchestrators.delete(orchestratorId);
|
|
3097
|
-
this.broadcast('plan:cancelled', { orchestratorId });
|
|
3098
|
-
return { success: true, data: { cancelled: orchestratorId } };
|
|
3099
|
-
}
|
|
3100
|
-
// Otherwise cancel all active plan generations
|
|
3101
|
-
const cancelled = [];
|
|
3102
|
-
for (const [id, orchestrator] of this.activePlanOrchestrators) {
|
|
3103
|
-
console.log(`[API] Cancelling plan generation ${id}`);
|
|
3104
|
-
await orchestrator.cancel();
|
|
3105
|
-
cancelled.push(id);
|
|
3106
|
-
this.broadcast('plan:cancelled', { orchestratorId: id });
|
|
3107
|
-
}
|
|
3108
|
-
this.activePlanOrchestrators.clear();
|
|
3109
|
-
return { success: true, data: { cancelled } };
|
|
3110
|
-
});
|
|
3111
|
-
// Get ralph-wizard files for a case (prompts and results)
|
|
3112
|
-
this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
|
3113
|
-
const { caseName } = req.params;
|
|
3114
|
-
const casesDir = join(homedir(), 'codeman-cases');
|
|
3115
|
-
let casePath = join(casesDir, caseName);
|
|
3116
|
-
// Security: Path traversal protection - use relative path check
|
|
3117
|
-
const resolvedCase = resolve(casePath);
|
|
3118
|
-
const resolvedBase = resolve(casesDir);
|
|
3119
|
-
const relPath = relative(resolvedBase, resolvedCase);
|
|
3120
|
-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
|
3121
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
|
3122
|
-
}
|
|
3123
|
-
// Check linked cases if path doesn't exist
|
|
3124
|
-
if (!existsSync(casePath)) {
|
|
3125
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
3126
|
-
try {
|
|
3127
|
-
const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
3128
|
-
if (linkedCases[caseName]) {
|
|
3129
|
-
casePath = linkedCases[caseName];
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
3132
|
-
catch {
|
|
3133
|
-
// No linked cases file
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
|
-
const wizardDir = join(casePath, 'ralph-wizard');
|
|
3137
|
-
if (!existsSync(wizardDir)) {
|
|
3138
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found');
|
|
3139
|
-
}
|
|
3140
|
-
// List all subdirectories and their files
|
|
3141
|
-
const files = [];
|
|
3142
|
-
const entries = readdirSync(wizardDir, { withFileTypes: true });
|
|
3143
|
-
for (const entry of entries) {
|
|
3144
|
-
if (entry.isDirectory()) {
|
|
3145
|
-
const agentDir = join(wizardDir, entry.name);
|
|
3146
|
-
const agentFiles = {
|
|
3147
|
-
agentType: entry.name,
|
|
3148
|
-
};
|
|
3149
|
-
if (existsSync(join(agentDir, 'prompt.md'))) {
|
|
3150
|
-
agentFiles.promptFile = `${entry.name}/prompt.md`;
|
|
3151
|
-
}
|
|
3152
|
-
if (existsSync(join(agentDir, 'result.json'))) {
|
|
3153
|
-
agentFiles.resultFile = `${entry.name}/result.json`;
|
|
3154
|
-
}
|
|
3155
|
-
if (agentFiles.promptFile || agentFiles.resultFile) {
|
|
3156
|
-
files.push(agentFiles);
|
|
3157
|
-
}
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
return { success: true, data: { files, caseName } };
|
|
3161
|
-
});
|
|
3162
|
-
// Read a specific ralph-wizard file
|
|
3163
|
-
// Cache disabled to ensure fresh prompts when starting new plan generations
|
|
3164
|
-
this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
|
3165
|
-
const { caseName, filePath } = req.params;
|
|
3166
|
-
const casesDir = join(homedir(), 'codeman-cases');
|
|
3167
|
-
let casePath = join(casesDir, caseName);
|
|
3168
|
-
// Prevent browser caching - prompts change between plan generations
|
|
3169
|
-
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
3170
|
-
reply.header('Pragma', 'no-cache');
|
|
3171
|
-
reply.header('Expires', '0');
|
|
3172
|
-
// Security: Path traversal protection for case name - use relative path check
|
|
3173
|
-
const resolvedCase = resolve(casePath);
|
|
3174
|
-
const resolvedBase = resolve(casesDir);
|
|
3175
|
-
const relPath = relative(resolvedBase, resolvedCase);
|
|
3176
|
-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
|
3177
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
|
3178
|
-
}
|
|
3179
|
-
// Check linked cases if path doesn't exist
|
|
3180
|
-
if (!existsSync(casePath)) {
|
|
3181
|
-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
3182
|
-
try {
|
|
3183
|
-
const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
3184
|
-
if (linkedCases[caseName]) {
|
|
3185
|
-
casePath = linkedCases[caseName];
|
|
3186
|
-
}
|
|
3187
|
-
}
|
|
3188
|
-
catch {
|
|
3189
|
-
// No linked cases file
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
|
-
const wizardDir = join(casePath, 'ralph-wizard');
|
|
3193
|
-
// Decode the file path (it may be URL encoded)
|
|
3194
|
-
const decodedPath = decodeURIComponent(filePath);
|
|
3195
|
-
const fullPath = join(wizardDir, decodedPath);
|
|
3196
|
-
// Security: ensure path is within wizard directory
|
|
3197
|
-
const resolvedPath = resolve(fullPath);
|
|
3198
|
-
const resolvedWizard = resolve(wizardDir);
|
|
3199
|
-
if (!resolvedPath.startsWith(resolvedWizard)) {
|
|
3200
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path');
|
|
3201
|
-
}
|
|
3202
|
-
let content;
|
|
3203
|
-
try {
|
|
3204
|
-
content = await fs.readFile(fullPath, 'utf-8');
|
|
3205
|
-
}
|
|
3206
|
-
catch (err) {
|
|
3207
|
-
if (err.code === 'ENOENT') {
|
|
3208
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
|
3209
|
-
}
|
|
3210
|
-
throw err;
|
|
3211
|
-
}
|
|
3212
|
-
const isJson = filePath.endsWith('.json');
|
|
3213
|
-
// Parse JSON content safely (may contain invalid JSON or unescaped control characters)
|
|
3214
|
-
let parsed = null;
|
|
3215
|
-
if (isJson) {
|
|
3216
|
-
try {
|
|
3217
|
-
parsed = JSON.parse(content);
|
|
3218
|
-
}
|
|
3219
|
-
catch {
|
|
3220
|
-
// Try repairing common JSON issues (unescaped control characters, trailing commas)
|
|
3221
|
-
try {
|
|
3222
|
-
let repaired = content;
|
|
3223
|
-
// Fix trailing commas before closing brackets
|
|
3224
|
-
repaired = repaired.replace(/,(\s*[\]}])/g, '$1');
|
|
3225
|
-
// Fix unescaped control characters within JSON strings
|
|
3226
|
-
repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => {
|
|
3227
|
-
return match
|
|
3228
|
-
.replace(/\n/g, '\\n')
|
|
3229
|
-
.replace(/\r/g, '\\r')
|
|
3230
|
-
.replace(/\t/g, '\\t')
|
|
3231
|
-
.replace(
|
|
3232
|
-
// eslint-disable-next-line no-control-regex
|
|
3233
|
-
/[\x00-\x1f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
3234
|
-
});
|
|
3235
|
-
parsed = JSON.parse(repaired);
|
|
3236
|
-
}
|
|
3237
|
-
catch {
|
|
3238
|
-
// Still invalid - return null for parsed, content available as raw string
|
|
3239
|
-
}
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
return {
|
|
3243
|
-
success: true,
|
|
3244
|
-
data: {
|
|
3245
|
-
content,
|
|
3246
|
-
filePath: decodedPath,
|
|
3247
|
-
isJson,
|
|
3248
|
-
parsed,
|
|
3249
|
-
},
|
|
3250
|
-
};
|
|
3251
|
-
});
|
|
3252
|
-
// ============ Plan Management Endpoints ============
|
|
3253
|
-
// These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
|
|
3254
|
-
// Update a specific plan task (status, attempts, errors)
|
|
3255
|
-
this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
|
3256
|
-
const { id, taskId } = req.params;
|
|
3257
|
-
const session = this.sessions.get(id);
|
|
3258
|
-
if (!session) {
|
|
3259
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3260
|
-
}
|
|
3261
|
-
const tracker = session.ralphTracker;
|
|
3262
|
-
if (!tracker) {
|
|
3263
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
3264
|
-
}
|
|
3265
|
-
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
|
3266
|
-
if (!ptuResult.success) {
|
|
3267
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
3268
|
-
}
|
|
3269
|
-
const update = ptuResult.data;
|
|
3270
|
-
const result = tracker.updatePlanTask(taskId, update);
|
|
3271
|
-
if (!result.success) {
|
|
3272
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
|
3273
|
-
}
|
|
3274
|
-
this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
|
|
3275
|
-
return { success: true, data: result.task };
|
|
3276
|
-
});
|
|
3277
|
-
// Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
|
|
3278
|
-
this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
|
3279
|
-
const { id } = req.params;
|
|
3280
|
-
const session = this.sessions.get(id);
|
|
3281
|
-
if (!session) {
|
|
3282
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3283
|
-
}
|
|
3284
|
-
const tracker = session.ralphTracker;
|
|
3285
|
-
if (!tracker) {
|
|
3286
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
3287
|
-
}
|
|
3288
|
-
const checkpoint = tracker.generateCheckpointReview();
|
|
3289
|
-
this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
|
|
3290
|
-
return { success: true, data: checkpoint };
|
|
3291
|
-
});
|
|
3292
|
-
// Get plan version history
|
|
3293
|
-
this.app.get('/api/sessions/:id/plan/history', async (req) => {
|
|
3294
|
-
const { id } = req.params;
|
|
3295
|
-
const session = this.sessions.get(id);
|
|
3296
|
-
if (!session) {
|
|
3297
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3298
|
-
}
|
|
3299
|
-
const tracker = session.ralphTracker;
|
|
3300
|
-
if (!tracker) {
|
|
3301
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
3302
|
-
}
|
|
3303
|
-
return { success: true, data: tracker.getPlanHistory() };
|
|
3304
|
-
});
|
|
3305
|
-
// Rollback to a previous plan version
|
|
3306
|
-
this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
|
3307
|
-
const { id, version } = req.params;
|
|
3308
|
-
const session = this.sessions.get(id);
|
|
3309
|
-
if (!session) {
|
|
3310
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3311
|
-
}
|
|
3312
|
-
const tracker = session.ralphTracker;
|
|
3313
|
-
if (!tracker) {
|
|
3314
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
3315
|
-
}
|
|
3316
|
-
const result = tracker.rollbackToVersion(parseInt(version, 10));
|
|
3317
|
-
if (!result.success) {
|
|
3318
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
|
3319
|
-
}
|
|
3320
|
-
this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
|
|
3321
|
-
return { success: true, data: result.plan };
|
|
3322
|
-
});
|
|
3323
|
-
// Add a new task to the plan (for runtime adaptation)
|
|
3324
|
-
this.app.post('/api/sessions/:id/plan/task', async (req) => {
|
|
3325
|
-
const { id } = req.params;
|
|
3326
|
-
const session = this.sessions.get(id);
|
|
3327
|
-
if (!session) {
|
|
3328
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3329
|
-
}
|
|
3330
|
-
const tracker = session.ralphTracker;
|
|
3331
|
-
if (!tracker) {
|
|
3332
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
3333
|
-
}
|
|
3334
|
-
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
|
3335
|
-
if (!ptaResult.success) {
|
|
3336
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
3337
|
-
}
|
|
3338
|
-
const task = ptaResult.data;
|
|
3339
|
-
const result = tracker.addPlanTask(task);
|
|
3340
|
-
this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
|
|
3341
|
-
return { success: true, data: result.task };
|
|
3342
|
-
});
|
|
3343
|
-
// ============ App Settings Endpoints ============
|
|
3344
|
-
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
|
3345
|
-
this.app.get('/api/settings', async () => {
|
|
3346
|
-
try {
|
|
3347
|
-
const content = await fs.readFile(settingsPath, 'utf-8');
|
|
3348
|
-
return JSON.parse(content);
|
|
3349
|
-
}
|
|
3350
|
-
catch (err) {
|
|
3351
|
-
if (err.code !== 'ENOENT') {
|
|
3352
|
-
console.error('Failed to read settings:', err);
|
|
3353
|
-
}
|
|
3354
|
-
}
|
|
3355
|
-
return {};
|
|
3356
|
-
});
|
|
3357
|
-
this.app.put('/api/settings', async (req) => {
|
|
3358
|
-
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
|
3359
|
-
if (!settingsResult.success) {
|
|
3360
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
|
3361
|
-
}
|
|
3362
|
-
const settings = settingsResult.data;
|
|
3363
|
-
try {
|
|
3364
|
-
const dir = dirname(settingsPath);
|
|
3365
|
-
if (!existsSync(dir)) {
|
|
3366
|
-
mkdirSync(dir, { recursive: true });
|
|
3367
|
-
}
|
|
3368
|
-
let existing = {};
|
|
3369
|
-
try {
|
|
3370
|
-
existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
3371
|
-
}
|
|
3372
|
-
catch {
|
|
3373
|
-
/* ignore */
|
|
3374
|
-
}
|
|
3375
|
-
const merged = { ...existing, ...settings };
|
|
3376
|
-
await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2));
|
|
3377
|
-
// Handle subagent tracking toggle dynamically
|
|
3378
|
-
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
|
|
3379
|
-
if (subagentEnabled && !subagentWatcher.isRunning()) {
|
|
3380
|
-
subagentWatcher.start();
|
|
3381
|
-
console.log('Subagent watcher started via settings change');
|
|
3382
|
-
}
|
|
3383
|
-
else if (!subagentEnabled && subagentWatcher.isRunning()) {
|
|
3384
|
-
subagentWatcher.stop();
|
|
3385
|
-
console.log('Subagent watcher stopped via settings change');
|
|
3386
|
-
}
|
|
3387
|
-
// Handle image watcher toggle dynamically
|
|
3388
|
-
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
|
|
3389
|
-
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
|
|
3390
|
-
imageWatcher.start();
|
|
3391
|
-
// Re-watch all active sessions that have image watcher enabled
|
|
3392
|
-
for (const session of this.sessions.values()) {
|
|
3393
|
-
if (session.imageWatcherEnabled) {
|
|
3394
|
-
imageWatcher.watchSession(session.id, session.workingDir);
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
console.log('Image watcher started via settings change');
|
|
3398
|
-
}
|
|
3399
|
-
else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
|
|
3400
|
-
imageWatcher.stop();
|
|
3401
|
-
console.log('Image watcher stopped via settings change');
|
|
3402
|
-
}
|
|
3403
|
-
// Handle tunnel toggle dynamically
|
|
3404
|
-
if ('tunnelEnabled' in settings) {
|
|
3405
|
-
const tunnelEnabled = settings.tunnelEnabled;
|
|
3406
|
-
if (tunnelEnabled && !this.tunnelManager.isRunning()) {
|
|
3407
|
-
this.tunnelManager.start(this.port, this.https);
|
|
3408
|
-
console.log('Tunnel started via settings change');
|
|
3409
|
-
}
|
|
3410
|
-
else if (tunnelEnabled && this.tunnelManager.isRunning() && this.tunnelManager.getUrl()) {
|
|
3411
|
-
// Tunnel already running — re-emit so the client gets the URL
|
|
3412
|
-
this.broadcast('tunnel:started', { url: this.tunnelManager.getUrl() });
|
|
3413
|
-
console.log('Tunnel already running, re-broadcast URL to client');
|
|
3414
|
-
}
|
|
3415
|
-
else if (!tunnelEnabled && this.tunnelManager.isRunning()) {
|
|
3416
|
-
this.tunnelManager.stop();
|
|
3417
|
-
console.log('Tunnel stopped via settings change');
|
|
3418
|
-
}
|
|
3419
|
-
}
|
|
3420
|
-
return { success: true };
|
|
3421
|
-
}
|
|
3422
|
-
catch (err) {
|
|
3423
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
3424
|
-
}
|
|
3425
|
-
});
|
|
3426
|
-
// ============ Model Configuration Endpoints ============
|
|
3427
|
-
this.app.get('/api/execution/model-config', async () => {
|
|
3428
|
-
try {
|
|
3429
|
-
const content = await fs.readFile(settingsPath, 'utf-8');
|
|
3430
|
-
const settings = JSON.parse(content);
|
|
3431
|
-
return { success: true, data: settings.modelConfig || {} };
|
|
3432
|
-
}
|
|
3433
|
-
catch (err) {
|
|
3434
|
-
if (err.code !== 'ENOENT') {
|
|
3435
|
-
console.error('Failed to read model config:', err);
|
|
3436
|
-
}
|
|
3437
|
-
return { success: true, data: {} };
|
|
3438
|
-
}
|
|
3439
|
-
});
|
|
3440
|
-
this.app.put('/api/execution/model-config', async (req) => {
|
|
3441
|
-
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
|
3442
|
-
if (!mcResult.success) {
|
|
3443
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
|
3444
|
-
}
|
|
3445
|
-
const modelConfig = mcResult.data;
|
|
3446
|
-
try {
|
|
3447
|
-
let settings = {};
|
|
3448
|
-
try {
|
|
3449
|
-
const content = await fs.readFile(settingsPath, 'utf-8');
|
|
3450
|
-
settings = JSON.parse(content);
|
|
3451
|
-
}
|
|
3452
|
-
catch {
|
|
3453
|
-
// File doesn't exist yet, start fresh
|
|
3454
|
-
}
|
|
3455
|
-
settings.modelConfig = modelConfig;
|
|
3456
|
-
const dir = dirname(settingsPath);
|
|
3457
|
-
if (!existsSync(dir)) {
|
|
3458
|
-
mkdirSync(dir, { recursive: true });
|
|
3459
|
-
}
|
|
3460
|
-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
|
3461
|
-
return { success: true };
|
|
3462
|
-
}
|
|
3463
|
-
catch (err) {
|
|
3464
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
3465
|
-
}
|
|
3466
|
-
});
|
|
3467
|
-
// ============ CPU Priority Endpoints ============
|
|
3468
|
-
// Get Nice priority config for a session
|
|
3469
|
-
this.app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
|
3470
|
-
const { id } = req.params;
|
|
3471
|
-
const session = this.sessions.get(id);
|
|
3472
|
-
if (!session) {
|
|
3473
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3474
|
-
}
|
|
3475
|
-
return {
|
|
3476
|
-
success: true,
|
|
3477
|
-
nice: session.niceConfig,
|
|
3478
|
-
};
|
|
3479
|
-
});
|
|
3480
|
-
// Update Nice priority config for a session
|
|
3481
|
-
// Note: Changes only apply to NEW sessions, not running ones
|
|
3482
|
-
this.app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
|
3483
|
-
const { id } = req.params;
|
|
3484
|
-
const session = this.sessions.get(id);
|
|
3485
|
-
if (!session) {
|
|
3486
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3487
|
-
}
|
|
3488
|
-
const clResult = CpuLimitSchema.safeParse(req.body);
|
|
3489
|
-
if (!clResult.success) {
|
|
3490
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
3491
|
-
}
|
|
3492
|
-
const body = clResult.data;
|
|
3493
|
-
session.setNice(body);
|
|
3494
|
-
this.persistSessionState(session);
|
|
3495
|
-
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
|
3496
|
-
return {
|
|
3497
|
-
success: true,
|
|
3498
|
-
nice: session.niceConfig,
|
|
3499
|
-
note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
|
|
3500
|
-
};
|
|
3501
|
-
});
|
|
3502
|
-
// ============ Subagent Window State Endpoints ============
|
|
3503
|
-
// Persists minimized/open window states for cross-browser sync
|
|
3504
|
-
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
|
3505
|
-
this.app.get('/api/subagent-window-states', async () => {
|
|
3506
|
-
try {
|
|
3507
|
-
const content = await fs.readFile(windowStatesPath, 'utf-8');
|
|
3508
|
-
return JSON.parse(content);
|
|
3509
|
-
}
|
|
3510
|
-
catch (err) {
|
|
3511
|
-
if (err.code !== 'ENOENT') {
|
|
3512
|
-
console.error('Failed to read subagent window states:', err);
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
return { minimized: {}, open: [] };
|
|
3516
|
-
});
|
|
3517
|
-
this.app.put('/api/subagent-window-states', async (req) => {
|
|
3518
|
-
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
|
3519
|
-
if (!swResult.success) {
|
|
3520
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
|
3521
|
-
}
|
|
3522
|
-
const states = swResult.data;
|
|
3523
|
-
try {
|
|
3524
|
-
const dir = dirname(windowStatesPath);
|
|
3525
|
-
if (!existsSync(dir)) {
|
|
3526
|
-
mkdirSync(dir, { recursive: true });
|
|
3527
|
-
}
|
|
3528
|
-
await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
|
|
3529
|
-
return { success: true };
|
|
3530
|
-
}
|
|
3531
|
-
catch (err) {
|
|
3532
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
3533
|
-
}
|
|
3534
|
-
});
|
|
3535
|
-
// ============ Subagent Parent Associations ============
|
|
3536
|
-
// Persists which TAB each agent window connects to.
|
|
3537
|
-
// This is the PERMANENT record of agent -> tab associations.
|
|
3538
|
-
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
|
3539
|
-
this.app.get('/api/subagent-parents', async () => {
|
|
3540
|
-
try {
|
|
3541
|
-
const content = await fs.readFile(parentMapPath, 'utf-8');
|
|
3542
|
-
return JSON.parse(content);
|
|
3543
|
-
}
|
|
3544
|
-
catch (err) {
|
|
3545
|
-
if (err.code !== 'ENOENT') {
|
|
3546
|
-
console.error('Failed to read subagent parent map:', err);
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
return {};
|
|
3550
|
-
});
|
|
3551
|
-
this.app.put('/api/subagent-parents', async (req) => {
|
|
3552
|
-
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
|
3553
|
-
if (!spResult.success) {
|
|
3554
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
|
3555
|
-
}
|
|
3556
|
-
const parentMap = spResult.data;
|
|
3557
|
-
try {
|
|
3558
|
-
const dir = dirname(parentMapPath);
|
|
3559
|
-
if (!existsSync(dir)) {
|
|
3560
|
-
mkdirSync(dir, { recursive: true });
|
|
3561
|
-
}
|
|
3562
|
-
await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
|
|
3563
|
-
return { success: true };
|
|
3564
|
-
}
|
|
3565
|
-
catch (err) {
|
|
3566
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
3567
|
-
}
|
|
3568
|
-
});
|
|
3569
|
-
// ============ Mux Session Management Endpoints ============
|
|
3570
|
-
// Get all tracked mux sessions with stats
|
|
3571
|
-
this.app.get('/api/mux-sessions', async () => {
|
|
3572
|
-
const sessions = await this.mux.getSessionsWithStats();
|
|
3573
|
-
return {
|
|
3574
|
-
sessions,
|
|
3575
|
-
muxAvailable: this.mux.isAvailable(),
|
|
3576
|
-
};
|
|
3577
|
-
});
|
|
3578
|
-
// Kill a mux session
|
|
3579
|
-
this.app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
|
3580
|
-
const { sessionId } = req.params;
|
|
3581
|
-
const success = await this.mux.killSession(sessionId);
|
|
3582
|
-
return { success };
|
|
3583
|
-
});
|
|
3584
|
-
// Reconcile mux sessions (find dead ones)
|
|
3585
|
-
this.app.post('/api/mux-sessions/reconcile', async () => {
|
|
3586
|
-
const result = await this.mux.reconcileSessions();
|
|
3587
|
-
return result;
|
|
3588
|
-
});
|
|
3589
|
-
// Start stats collection
|
|
3590
|
-
this.app.post('/api/mux-sessions/stats/start', async () => {
|
|
3591
|
-
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
|
3592
|
-
return { success: true };
|
|
3593
|
-
});
|
|
3594
|
-
// Stop stats collection
|
|
3595
|
-
this.app.post('/api/mux-sessions/stats/stop', async () => {
|
|
3596
|
-
this.mux.stopStatsCollection();
|
|
3597
|
-
return { success: true };
|
|
3598
|
-
});
|
|
3599
|
-
// System stats endpoint for frontend header display
|
|
3600
|
-
this.app.get('/api/system/stats', async () => {
|
|
3601
|
-
return this.getSystemStats();
|
|
3602
|
-
});
|
|
3603
|
-
// ========== Subagent Monitoring (Claude Code Background Agents) ==========
|
|
3604
|
-
// List all known subagents
|
|
3605
|
-
this.app.get('/api/subagents', async (req) => {
|
|
3606
|
-
const { minutes } = req.query;
|
|
3607
|
-
const subagents = minutes
|
|
3608
|
-
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
|
3609
|
-
: subagentWatcher.getSubagents();
|
|
3610
|
-
return { success: true, data: subagents };
|
|
3611
|
-
});
|
|
3612
|
-
// Get subagents for a specific session (by working directory)
|
|
3613
|
-
this.app.get('/api/sessions/:id/subagents', async (req) => {
|
|
3614
|
-
const { id } = req.params;
|
|
3615
|
-
const session = this.sessions.get(id);
|
|
3616
|
-
if (!session) {
|
|
3617
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
|
3618
|
-
}
|
|
3619
|
-
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
|
3620
|
-
return { success: true, data: subagents };
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.app = Fastify({ logger: false });
|
|
159
|
+
}
|
|
160
|
+
this.mux = createMultiplexer();
|
|
161
|
+
// Set up mux event listeners
|
|
162
|
+
this.mux.on('sessionCreated', (session) => {
|
|
163
|
+
this.broadcast('mux:created', session);
|
|
3621
164
|
});
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
const { agentId } = req.params;
|
|
3625
|
-
const info = subagentWatcher.getSubagent(agentId);
|
|
3626
|
-
if (!info) {
|
|
3627
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`);
|
|
3628
|
-
}
|
|
3629
|
-
return { success: true, data: info };
|
|
165
|
+
this.mux.on('sessionKilled', (data) => {
|
|
166
|
+
this.broadcast('mux:killed', data);
|
|
3630
167
|
});
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
const formatted = subagentWatcher.formatTranscript(transcript);
|
|
3639
|
-
return { success: true, data: { formatted, entryCount: transcript.length } };
|
|
3640
|
-
}
|
|
3641
|
-
return { success: true, data: transcript };
|
|
168
|
+
this.mux.on('sessionDied', (data) => {
|
|
169
|
+
getLifecycleLog().log({
|
|
170
|
+
event: 'mux_died',
|
|
171
|
+
sessionId: data.sessionId || 'unknown',
|
|
172
|
+
extra: data,
|
|
173
|
+
});
|
|
174
|
+
this.broadcast('mux:died', data);
|
|
3642
175
|
});
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
const { agentId } = req.params;
|
|
3646
|
-
const info = subagentWatcher.getSubagent(agentId);
|
|
3647
|
-
if (!info) {
|
|
3648
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found');
|
|
3649
|
-
}
|
|
3650
|
-
const killed = await subagentWatcher.killSubagent(agentId);
|
|
3651
|
-
if (killed) {
|
|
3652
|
-
return { success: true, data: { agentId, status: 'killed' } };
|
|
3653
|
-
}
|
|
3654
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
|
176
|
+
this.mux.on('statsUpdated', (sessions) => {
|
|
177
|
+
this.broadcast('mux:statsUpdated', sessions);
|
|
3655
178
|
});
|
|
3656
|
-
//
|
|
3657
|
-
this.
|
|
3658
|
-
|
|
3659
|
-
|
|
179
|
+
// Set up subagent watcher listeners
|
|
180
|
+
this.setupSubagentWatcherListeners();
|
|
181
|
+
// Set up image watcher listeners
|
|
182
|
+
this.setupImageWatcherListeners();
|
|
183
|
+
// Set up team watcher listeners
|
|
184
|
+
this.setupTeamWatcherListeners();
|
|
185
|
+
// Set up tunnel manager listeners
|
|
186
|
+
this.tunnelManager.on('started', (data) => {
|
|
187
|
+
this.broadcast('tunnel:started', data);
|
|
3660
188
|
});
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
const cleared = subagentWatcher.clearAll();
|
|
3664
|
-
return { success: true, data: { cleared } };
|
|
189
|
+
this.tunnelManager.on('stopped', () => {
|
|
190
|
+
this.broadcast('tunnel:stopped', {});
|
|
3665
191
|
});
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
this.app.get('/api/teams', async () => {
|
|
3669
|
-
return { success: true, data: this.teamWatcher.getTeams() };
|
|
192
|
+
this.tunnelManager.on('error', (message) => {
|
|
193
|
+
this.broadcast('tunnel:error', { message });
|
|
3670
194
|
});
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
const { name } = req.params;
|
|
3674
|
-
return { success: true, data: this.teamWatcher.getTeamTasks(name) };
|
|
195
|
+
this.tunnelManager.on('progress', (data) => {
|
|
196
|
+
this.broadcast('tunnel:progress', data);
|
|
3675
197
|
});
|
|
3676
|
-
//
|
|
3677
|
-
this.
|
|
3678
|
-
const
|
|
3679
|
-
if (
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
if (!this.sessions.has(sessionId)) {
|
|
3684
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
3685
|
-
}
|
|
3686
|
-
// Signal the respawn controller based on hook event type
|
|
3687
|
-
const controller = this.respawnControllers.get(sessionId);
|
|
3688
|
-
if (controller) {
|
|
3689
|
-
if (event === 'elicitation_dialog') {
|
|
3690
|
-
// Block auto-accept for question prompts
|
|
3691
|
-
controller.signalElicitation();
|
|
3692
|
-
}
|
|
3693
|
-
else if (event === 'stop') {
|
|
3694
|
-
// DEFINITIVE idle signal - Claude finished responding
|
|
3695
|
-
controller.signalStopHook();
|
|
198
|
+
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
|
199
|
+
this.tunnelManager.on('qrTokenRotated', async () => {
|
|
200
|
+
const url = this.tunnelManager.getUrl();
|
|
201
|
+
if (url && process.env.CODEMAN_PASSWORD) {
|
|
202
|
+
try {
|
|
203
|
+
const svg = await this.tunnelManager.getQrSvg(url);
|
|
204
|
+
this.broadcast('tunnel:qrRotated', { svg });
|
|
3696
205
|
}
|
|
3697
|
-
|
|
3698
|
-
//
|
|
3699
|
-
controller.signalIdlePrompt();
|
|
206
|
+
catch {
|
|
207
|
+
// QR generation failed — skip this rotation
|
|
3700
208
|
}
|
|
3701
209
|
}
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
210
|
+
});
|
|
211
|
+
this.tunnelManager.on('qrTokenRegenerated', async () => {
|
|
212
|
+
const url = this.tunnelManager.getUrl();
|
|
213
|
+
if (url && process.env.CODEMAN_PASSWORD) {
|
|
214
|
+
try {
|
|
215
|
+
const svg = await this.tunnelManager.getQrSvg(url);
|
|
216
|
+
this.broadcast('tunnel:qrRegenerated', { svg });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// QR generation failed — skip
|
|
3707
220
|
}
|
|
3708
221
|
}
|
|
3709
|
-
// Sanitize forwarded data: only include known safe fields, limit size
|
|
3710
|
-
const safeData = sanitizeHookData(data);
|
|
3711
|
-
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
|
3712
|
-
// Send push notifications for hook events
|
|
3713
|
-
const session = this.sessions.get(sessionId);
|
|
3714
|
-
const sessionName = session?.name ?? sessionId.slice(0, 8);
|
|
3715
|
-
this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
|
|
3716
|
-
// Track in run summary
|
|
3717
|
-
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
|
3718
|
-
if (summaryTracker) {
|
|
3719
|
-
summaryTracker.recordHookEvent(event, safeData);
|
|
3720
|
-
}
|
|
3721
|
-
return { success: true };
|
|
3722
|
-
});
|
|
3723
|
-
// ========== Web Push ==========
|
|
3724
|
-
this.app.get('/api/push/vapid-key', async () => {
|
|
3725
|
-
return { success: true, data: { publicKey: this.pushStore.getPublicKey() } };
|
|
3726
222
|
});
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Set up event listeners for subagent watcher.
|
|
226
|
+
* Broadcasts real-time subagent activity to SSE clients.
|
|
227
|
+
*
|
|
228
|
+
* The SubagentWatcher now extracts descriptions directly from the parent session's
|
|
229
|
+
* transcript, which contains the exact Task tool call with the description parameter.
|
|
230
|
+
* This is more reliable than the previous timing-based correlation approach.
|
|
231
|
+
*/
|
|
232
|
+
setupSubagentWatcherListeners() {
|
|
233
|
+
// Store handlers for cleanup on shutdown
|
|
234
|
+
this.subagentWatcherHandlers = {
|
|
235
|
+
discovered: (info) => this.broadcast('subagent:discovered', info),
|
|
236
|
+
updated: (info) => this.broadcast('subagent:updated', info),
|
|
237
|
+
toolCall: (data) => this.broadcast('subagent:tool_call', data),
|
|
238
|
+
toolResult: (data) => this.broadcast('subagent:tool_result', data),
|
|
239
|
+
progress: (data) => this.broadcast('subagent:progress', data),
|
|
240
|
+
message: (data) => this.broadcast('subagent:message', data),
|
|
241
|
+
completed: (info) => this.broadcast('subagent:completed', info),
|
|
242
|
+
error: (error, agentId) => {
|
|
243
|
+
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
|
247
|
+
subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated);
|
|
248
|
+
subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
|
249
|
+
subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
|
250
|
+
subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress);
|
|
251
|
+
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
|
252
|
+
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
|
253
|
+
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Clean up subagent watcher listeners to prevent memory leaks.
|
|
257
|
+
*/
|
|
258
|
+
cleanupSubagentWatcherListeners() {
|
|
259
|
+
if (this.subagentWatcherHandlers) {
|
|
260
|
+
subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
|
261
|
+
subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated);
|
|
262
|
+
subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
|
263
|
+
subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
|
264
|
+
subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress);
|
|
265
|
+
subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message);
|
|
266
|
+
subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed);
|
|
267
|
+
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
|
|
268
|
+
this.subagentWatcherHandlers = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Set up event listeners for image watcher.
|
|
273
|
+
* Broadcasts image detection events to SSE clients for auto-popup.
|
|
274
|
+
*/
|
|
275
|
+
setupImageWatcherListeners() {
|
|
276
|
+
// Store handlers for cleanup on shutdown
|
|
277
|
+
this.imageWatcherHandlers = {
|
|
278
|
+
detected: (event) => this.broadcast('image:detected', event),
|
|
279
|
+
error: (error, sessionId) => {
|
|
280
|
+
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
|
284
|
+
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Clean up image watcher listeners to prevent memory leaks.
|
|
288
|
+
*/
|
|
289
|
+
cleanupImageWatcherListeners() {
|
|
290
|
+
if (this.imageWatcherHandlers) {
|
|
291
|
+
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
|
292
|
+
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
|
293
|
+
this.imageWatcherHandlers = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Set up event listeners for team watcher.
|
|
298
|
+
* Broadcasts team activity events to SSE clients.
|
|
299
|
+
*/
|
|
300
|
+
setupTeamWatcherListeners() {
|
|
301
|
+
this.teamWatcherHandlers = {
|
|
302
|
+
teamCreated: (config) => this.broadcast('team:created', config),
|
|
303
|
+
teamUpdated: (config) => this.broadcast('team:updated', config),
|
|
304
|
+
teamRemoved: (config) => this.broadcast('team:removed', config),
|
|
305
|
+
taskUpdated: (data) => this.broadcast('team:taskUpdated', data),
|
|
306
|
+
};
|
|
307
|
+
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
|
308
|
+
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
|
309
|
+
this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
|
310
|
+
this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Clean up team watcher listeners to prevent memory leaks.
|
|
314
|
+
*/
|
|
315
|
+
cleanupTeamWatcherListeners() {
|
|
316
|
+
if (this.teamWatcherHandlers) {
|
|
317
|
+
this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
|
|
318
|
+
this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
|
319
|
+
this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
|
320
|
+
this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
|
321
|
+
this.teamWatcherHandlers = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Build a route context object satisfying all 5 port interfaces.
|
|
326
|
+
* Single object with zero runtime cost — ISP enforced at the type level.
|
|
327
|
+
*/
|
|
328
|
+
createRouteContext() {
|
|
329
|
+
return {
|
|
330
|
+
// SessionPort
|
|
331
|
+
sessions: this.sessions,
|
|
332
|
+
addSession: (session) => {
|
|
333
|
+
this.sessions.set(session.id, session);
|
|
334
|
+
},
|
|
335
|
+
cleanupSession: this.cleanupSession.bind(this),
|
|
336
|
+
setupSessionListeners: this.setupSessionListeners.bind(this),
|
|
337
|
+
persistSessionState: this.persistSessionState.bind(this),
|
|
338
|
+
persistSessionStateNow: this._persistSessionStateNow.bind(this),
|
|
339
|
+
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
|
|
340
|
+
// EventPort
|
|
341
|
+
broadcast: this.broadcast.bind(this),
|
|
342
|
+
sendPushNotifications: this.sendPushNotifications.bind(this),
|
|
343
|
+
batchTerminalData: this.batchTerminalData.bind(this),
|
|
344
|
+
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
|
345
|
+
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
|
346
|
+
// RespawnPort
|
|
347
|
+
respawnControllers: this.respawnControllers,
|
|
348
|
+
respawnTimers: this.respawnTimers,
|
|
349
|
+
setupRespawnListeners: this.setupRespawnListeners.bind(this),
|
|
350
|
+
setupTimedRespawn: this.setupTimedRespawn.bind(this),
|
|
351
|
+
restoreRespawnController: this.restoreRespawnController.bind(this),
|
|
352
|
+
saveRespawnConfig: this.saveRespawnConfig.bind(this),
|
|
353
|
+
// ConfigPort
|
|
354
|
+
store: this.store,
|
|
355
|
+
port: this.port,
|
|
356
|
+
https: this.https,
|
|
357
|
+
testMode: this.testMode,
|
|
358
|
+
serverStartTime: this.serverStartTime,
|
|
359
|
+
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
|
360
|
+
getModelConfig: this.getModelConfig.bind(this),
|
|
361
|
+
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
|
362
|
+
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
|
363
|
+
getLightState: this.getLightState.bind(this),
|
|
364
|
+
getLightSessionsState: this.getLightSessionsState.bind(this),
|
|
365
|
+
startTranscriptWatcher: this.startTranscriptWatcher.bind(this),
|
|
366
|
+
stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this),
|
|
367
|
+
// InfraPort
|
|
368
|
+
mux: this.mux,
|
|
369
|
+
runSummaryTrackers: this.runSummaryTrackers,
|
|
370
|
+
activePlanOrchestrators: this.activePlanOrchestrators,
|
|
371
|
+
scheduledRuns: this.scheduledRuns,
|
|
372
|
+
teamWatcher: this.teamWatcher,
|
|
373
|
+
tunnelManager: this.tunnelManager,
|
|
374
|
+
pushStore: this.pushStore,
|
|
375
|
+
startScheduledRun: this.startScheduledRun.bind(this),
|
|
376
|
+
stopScheduledRun: this.stopScheduledRun.bind(this),
|
|
377
|
+
// AuthPort
|
|
378
|
+
authSessions: this.authSessions,
|
|
379
|
+
qrAuthFailures: this.qrAuthFailures,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async setupRoutes() {
|
|
383
|
+
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
|
|
384
|
+
// so the route handler can read the raw stream directly.
|
|
385
|
+
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
|
|
386
|
+
done(null);
|
|
3742
387
|
});
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
}
|
|
3749
|
-
const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences);
|
|
3750
|
-
if (!updated) {
|
|
3751
|
-
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
|
3752
|
-
}
|
|
3753
|
-
return { success: true };
|
|
388
|
+
// Enable gzip/brotli compression for all responses.
|
|
389
|
+
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
|
|
390
|
+
// Threshold 1024 = don't compress tiny responses (headers > savings).
|
|
391
|
+
await this.app.register(fastifyCompress, {
|
|
392
|
+
threshold: 1024,
|
|
3754
393
|
});
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
394
|
+
// Cookie plugin (needed for auth session tokens)
|
|
395
|
+
await this.app.register(fastifyCookie);
|
|
396
|
+
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
|
397
|
+
const authState = registerAuthMiddleware(this.app, this.https);
|
|
398
|
+
if (authState) {
|
|
399
|
+
this.authSessions = authState.authSessions;
|
|
400
|
+
this.authFailures = authState.authFailures;
|
|
401
|
+
this.qrAuthFailures = authState.qrAuthFailures;
|
|
402
|
+
}
|
|
403
|
+
// Security headers + CORS
|
|
404
|
+
registerSecurityHeaders(this.app, this.https);
|
|
405
|
+
// Service worker must never be cached — browsers check for SW updates on navigation
|
|
406
|
+
this.app.get('/sw.js', async (_req, reply) => {
|
|
407
|
+
return reply
|
|
408
|
+
.header('Cache-Control', 'no-cache, no-store')
|
|
409
|
+
.header('Service-Worker-Allowed', '/')
|
|
410
|
+
.type('application/javascript')
|
|
411
|
+
.sendFile('sw.js', join(__dirname, 'public'));
|
|
3762
412
|
});
|
|
3763
|
-
//
|
|
3764
|
-
//
|
|
3765
|
-
this.app.
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
|
3772
|
-
if (!boundaryMatch) {
|
|
3773
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
|
|
3774
|
-
}
|
|
3775
|
-
// Collect raw body
|
|
3776
|
-
const chunks = [];
|
|
3777
|
-
let totalSize = 0;
|
|
3778
|
-
for await (const chunk of req.raw) {
|
|
3779
|
-
totalSize += chunk.length;
|
|
3780
|
-
if (totalSize > MAX_SCREENSHOT_SIZE) {
|
|
3781
|
-
reply.status(413);
|
|
3782
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
|
3783
|
-
}
|
|
3784
|
-
chunks.push(chunk);
|
|
3785
|
-
}
|
|
3786
|
-
const body = Buffer.concat(chunks);
|
|
3787
|
-
// Extract file from multipart body
|
|
3788
|
-
const boundary = '--' + boundaryMatch[1];
|
|
3789
|
-
const boundaryBuf = Buffer.from(boundary);
|
|
3790
|
-
const parts = [];
|
|
3791
|
-
let pos = 0;
|
|
3792
|
-
// Find each part between boundaries
|
|
3793
|
-
while (pos < body.length) {
|
|
3794
|
-
const start = body.indexOf(boundaryBuf, pos);
|
|
3795
|
-
if (start === -1)
|
|
3796
|
-
break;
|
|
3797
|
-
const afterBoundary = start + boundaryBuf.length;
|
|
3798
|
-
// Check for closing boundary (--)
|
|
3799
|
-
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d)
|
|
3800
|
-
break;
|
|
3801
|
-
// Skip \r\n after boundary
|
|
3802
|
-
const headerStart = afterBoundary + 2;
|
|
3803
|
-
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
|
3804
|
-
if (headerEnd === -1)
|
|
3805
|
-
break;
|
|
3806
|
-
const headers = body.subarray(headerStart, headerEnd).toString();
|
|
3807
|
-
const dataStart = headerEnd + 4;
|
|
3808
|
-
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
|
3809
|
-
// Data ends 2 bytes before next boundary (\r\n)
|
|
3810
|
-
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
|
3811
|
-
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
|
3812
|
-
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
|
3813
|
-
}
|
|
3814
|
-
const filePart = parts.find((p) => p.headers.includes('name="file"'));
|
|
3815
|
-
if (!filePart || filePart.data.length === 0) {
|
|
3816
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded');
|
|
3817
|
-
}
|
|
3818
|
-
// Determine extension from Content-Type or filename
|
|
3819
|
-
let ext = '.png';
|
|
3820
|
-
const filenameMatch = filePart.headers.match(/filename="(.+?)"/);
|
|
3821
|
-
if (filenameMatch) {
|
|
3822
|
-
const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i);
|
|
3823
|
-
if (origExt)
|
|
3824
|
-
ext = origExt[0].toLowerCase();
|
|
3825
|
-
}
|
|
3826
|
-
const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i);
|
|
3827
|
-
if (ctMatch) {
|
|
3828
|
-
const map = {
|
|
3829
|
-
png: '.png',
|
|
3830
|
-
jpeg: '.jpg',
|
|
3831
|
-
webp: '.webp',
|
|
3832
|
-
gif: '.gif',
|
|
3833
|
-
};
|
|
3834
|
-
ext = map[ctMatch[1].toLowerCase()] ?? ext;
|
|
3835
|
-
}
|
|
3836
|
-
// Save to ~/.codeman/screenshots/
|
|
3837
|
-
if (!existsSync(SCREENSHOTS_DIR)) {
|
|
3838
|
-
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
3839
|
-
}
|
|
3840
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
3841
|
-
const filename = `screenshot_${timestamp}${ext}`;
|
|
3842
|
-
const filepath = join(SCREENSHOTS_DIR, filename);
|
|
3843
|
-
await fs.writeFile(filepath, filePart.data);
|
|
3844
|
-
return { success: true, path: filepath, filename };
|
|
413
|
+
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
|
|
414
|
+
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
|
415
|
+
await this.app.register(fastifyStatic, {
|
|
416
|
+
root: join(__dirname, 'public'),
|
|
417
|
+
prefix: '/',
|
|
418
|
+
maxAge: '1y',
|
|
419
|
+
immutable: true,
|
|
420
|
+
preCompressed: true,
|
|
3845
421
|
});
|
|
3846
|
-
//
|
|
3847
|
-
this.app.get('/api/
|
|
3848
|
-
|
|
3849
|
-
|
|
422
|
+
// SSE endpoint for real-time updates
|
|
423
|
+
this.app.get('/api/events', (req, reply) => {
|
|
424
|
+
// Enforce SSE client limit to prevent memory exhaustion from too many connections
|
|
425
|
+
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
|
|
426
|
+
reply.code(503).send('Too many SSE connections');
|
|
427
|
+
return;
|
|
3850
428
|
}
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
429
|
+
reply.raw.writeHead(200, {
|
|
430
|
+
'Content-Type': 'text/event-stream',
|
|
431
|
+
'Cache-Control': 'no-cache',
|
|
432
|
+
Connection: 'keep-alive',
|
|
433
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
434
|
+
});
|
|
435
|
+
this.sseClients.add(reply);
|
|
436
|
+
// Send initial state
|
|
437
|
+
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
|
438
|
+
// Buffers are fetched on-demand when switching tabs
|
|
439
|
+
this.sendSSE(reply, 'init', this.getLightState());
|
|
440
|
+
req.raw.on('close', () => {
|
|
441
|
+
this.sseClients.delete(reply);
|
|
442
|
+
this.backpressuredClients.delete(reply);
|
|
443
|
+
});
|
|
3858
444
|
});
|
|
3859
|
-
//
|
|
3860
|
-
this.app.
|
|
3861
|
-
const
|
|
3862
|
-
|
|
3863
|
-
if (
|
|
3864
|
-
reply.
|
|
3865
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
|
445
|
+
// Global error handler for structured errors thrown by findSessionOrFail
|
|
446
|
+
this.app.setErrorHandler((error, _req, reply) => {
|
|
447
|
+
const statusCode = error.statusCode ?? 500;
|
|
448
|
+
const body = error.body;
|
|
449
|
+
if (body) {
|
|
450
|
+
reply.code(statusCode).send(body);
|
|
3866
451
|
}
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
452
|
+
else {
|
|
453
|
+
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// Register all route modules
|
|
457
|
+
const ctx = this.createRouteContext();
|
|
458
|
+
registerPushRoutes(this.app, ctx);
|
|
459
|
+
registerTeamRoutes(this.app, ctx);
|
|
460
|
+
registerMuxRoutes(this.app, ctx);
|
|
461
|
+
registerFileRoutes(this.app, ctx);
|
|
462
|
+
registerScheduledRoutes(this.app, ctx);
|
|
463
|
+
registerHookEventRoutes(this.app, ctx);
|
|
464
|
+
registerSystemRoutes(this.app, ctx);
|
|
465
|
+
registerCaseRoutes(this.app, ctx);
|
|
466
|
+
registerSessionRoutes(this.app, ctx);
|
|
467
|
+
registerRespawnRoutes(this.app, ctx);
|
|
468
|
+
registerRalphRoutes(this.app, ctx);
|
|
469
|
+
registerPlanRoutes(this.app, ctx);
|
|
3883
470
|
}
|
|
3884
471
|
/**
|
|
3885
472
|
* Start a transcript watcher for a session.
|
|
@@ -3936,16 +523,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
3936
523
|
}
|
|
3937
524
|
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
|
|
3938
525
|
persistSessionState(session) {
|
|
3939
|
-
|
|
3940
|
-
if (existing)
|
|
3941
|
-
clearTimeout(existing);
|
|
3942
|
-
this.persistDebounceTimers.set(session.id, setTimeout(() => {
|
|
3943
|
-
this.persistDebounceTimers.delete(session.id);
|
|
526
|
+
this.persistDeb.schedule(session.id, () => {
|
|
3944
527
|
// Session may have been removed during debounce
|
|
3945
528
|
if (this.sessions.has(session.id)) {
|
|
3946
529
|
this._persistSessionStateNow(session);
|
|
3947
530
|
}
|
|
3948
|
-
}
|
|
531
|
+
});
|
|
3949
532
|
}
|
|
3950
533
|
/** Persists full session state including respawn config to state.json */
|
|
3951
534
|
_persistSessionStateNow(session) {
|
|
@@ -4002,49 +585,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
4002
585
|
};
|
|
4003
586
|
this.mux.updateRespawnConfig(sessionId, persistedConfig);
|
|
4004
587
|
}
|
|
4005
|
-
// Get system CPU and memory usage
|
|
4006
|
-
getSystemStats() {
|
|
4007
|
-
try {
|
|
4008
|
-
const totalMem = totalmem();
|
|
4009
|
-
// macOS: os.freemem() only returns truly free pages, not cached/purgeable memory.
|
|
4010
|
-
// Use vm_stat to get accurate used memory (wired + active + compressed).
|
|
4011
|
-
let usedMem;
|
|
4012
|
-
if (process.platform === 'darwin') {
|
|
4013
|
-
try {
|
|
4014
|
-
const vmstat = execSync('vm_stat', { encoding: 'utf-8', timeout: 2000 });
|
|
4015
|
-
const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10);
|
|
4016
|
-
const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10);
|
|
4017
|
-
const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10);
|
|
4018
|
-
const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10);
|
|
4019
|
-
usedMem = (wired + active + compressed) * pageSize;
|
|
4020
|
-
}
|
|
4021
|
-
catch {
|
|
4022
|
-
usedMem = totalMem - freemem();
|
|
4023
|
-
}
|
|
4024
|
-
}
|
|
4025
|
-
else {
|
|
4026
|
-
usedMem = totalMem - freemem();
|
|
4027
|
-
}
|
|
4028
|
-
// CPU load average (1 min) as percentage (rough approximation)
|
|
4029
|
-
const load = loadavg()[0];
|
|
4030
|
-
const cpuCount = WebServer.CPU_COUNT;
|
|
4031
|
-
const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100));
|
|
4032
|
-
return {
|
|
4033
|
-
cpu: cpuPercent,
|
|
4034
|
-
memory: {
|
|
4035
|
-
usedMB: Math.round(usedMem / (1024 * 1024)),
|
|
4036
|
-
totalMB: Math.round(totalMem / (1024 * 1024)),
|
|
4037
|
-
percent: Math.round((usedMem / totalMem) * 100),
|
|
4038
|
-
},
|
|
4039
|
-
};
|
|
4040
|
-
}
|
|
4041
|
-
catch {
|
|
4042
|
-
return {
|
|
4043
|
-
cpu: 0,
|
|
4044
|
-
memory: { usedMB: 0, totalMB: 0, percent: 0 },
|
|
4045
|
-
};
|
|
4046
|
-
}
|
|
4047
|
-
}
|
|
4048
588
|
// Clean up all resources associated with a session
|
|
4049
589
|
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
|
4050
590
|
cleaningUp = new Set();
|
|
@@ -4119,11 +659,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
4119
659
|
this.runSummaryTrackers.delete(sessionId);
|
|
4120
660
|
}
|
|
4121
661
|
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
|
|
4122
|
-
|
|
4123
|
-
if (pendingPersist) {
|
|
4124
|
-
clearTimeout(pendingPersist);
|
|
4125
|
-
this.persistDebounceTimers.delete(sessionId);
|
|
4126
|
-
}
|
|
662
|
+
this.persistDeb.cancelKey(sessionId);
|
|
4127
663
|
// Clear batches, per-session timers, and pending state updates
|
|
4128
664
|
this.terminalBatches.delete(sessionId);
|
|
4129
665
|
this.terminalBatchSizes.delete(sessionId);
|
|
@@ -4332,11 +868,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
4332
868
|
this.stateUpdatePending.delete(session.id);
|
|
4333
869
|
this.lastTerminalEventTime.delete(session.id);
|
|
4334
870
|
// Clear pending persist-debounce timer
|
|
4335
|
-
|
|
4336
|
-
if (pendingPersist) {
|
|
4337
|
-
clearTimeout(pendingPersist);
|
|
4338
|
-
this.persistDebounceTimers.delete(session.id);
|
|
4339
|
-
}
|
|
871
|
+
this.persistDeb.cancelKey(session.id);
|
|
4340
872
|
// Close any active file streams
|
|
4341
873
|
fileStreamManager.closeSessionStreams(session.id);
|
|
4342
874
|
// Remove stored listener refs to break closure references (prevents memory leak).
|
|
@@ -5034,9 +1566,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5034
1566
|
// 1. The debounced session:updated follows within 500ms with the new state
|
|
5035
1567
|
// 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
|
|
5036
1568
|
// 3. Invalidating on every working/idle transition makes the 1s TTL useless
|
|
5037
|
-
if (event === 'session:created' ||
|
|
5038
|
-
event === 'session:deleted' ||
|
|
5039
|
-
event === 'session:updated') {
|
|
1569
|
+
if (event === 'session:created' || event === 'session:deleted' || event === 'session:updated') {
|
|
5040
1570
|
this.cachedLightState = null;
|
|
5041
1571
|
this.cachedSessionsList = null;
|
|
5042
1572
|
}
|
|
@@ -5143,11 +1673,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5143
1673
|
// Use composite key to avoid losing updates when multiple tasks update in same batch window
|
|
5144
1674
|
const key = `${sessionId}:${task.id}`;
|
|
5145
1675
|
this.taskUpdateBatches.set(key, { sessionId, task });
|
|
5146
|
-
if (!this.
|
|
5147
|
-
this.
|
|
1676
|
+
if (!this.taskUpdateBatchTimerId) {
|
|
1677
|
+
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(() => {
|
|
1678
|
+
this.taskUpdateBatchTimerId = null;
|
|
5148
1679
|
this.flushTaskUpdateBatches();
|
|
5149
|
-
|
|
5150
|
-
}, TASK_UPDATE_BATCH_INTERVAL);
|
|
1680
|
+
}, TASK_UPDATE_BATCH_INTERVAL, { description: 'task update batch flush' });
|
|
5151
1681
|
}
|
|
5152
1682
|
}
|
|
5153
1683
|
flushTaskUpdateBatches() {
|
|
@@ -5171,11 +1701,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5171
1701
|
if (this._isStopping)
|
|
5172
1702
|
return;
|
|
5173
1703
|
this.stateUpdatePending.add(sessionId);
|
|
5174
|
-
if (!this.
|
|
5175
|
-
this.
|
|
1704
|
+
if (!this.stateUpdateTimerId) {
|
|
1705
|
+
this.stateUpdateTimerId = this.cleanup.setTimeout(() => {
|
|
1706
|
+
this.stateUpdateTimerId = null;
|
|
5176
1707
|
this.flushStateUpdates();
|
|
5177
|
-
|
|
5178
|
-
}, STATE_UPDATE_DEBOUNCE_INTERVAL);
|
|
1708
|
+
}, STATE_UPDATE_DEBOUNCE_INTERVAL, { description: 'state update debounce flush' });
|
|
5179
1709
|
}
|
|
5180
1710
|
}
|
|
5181
1711
|
flushStateUpdates() {
|
|
@@ -5345,17 +1875,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5345
1875
|
// Set API URL for child processes (MCP server, spawned sessions)
|
|
5346
1876
|
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
|
5347
1877
|
// Start scheduled runs cleanup timer
|
|
5348
|
-
this.
|
|
1878
|
+
this.cleanup.setInterval(() => {
|
|
5349
1879
|
this.cleanupScheduledRuns();
|
|
5350
|
-
}, SCHEDULED_CLEANUP_INTERVAL);
|
|
1880
|
+
}, SCHEDULED_CLEANUP_INTERVAL, { description: 'scheduled runs cleanup' });
|
|
5351
1881
|
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
|
5352
|
-
this.
|
|
1882
|
+
this.cleanup.setInterval(() => {
|
|
5353
1883
|
this.cleanupDeadSSEClients();
|
|
5354
|
-
}, SSE_HEALTH_CHECK_INTERVAL);
|
|
1884
|
+
}, SSE_HEALTH_CHECK_INTERVAL, { description: 'SSE client health check' });
|
|
5355
1885
|
// Start token recording timer (every 5 minutes for long-running sessions)
|
|
5356
|
-
this.
|
|
1886
|
+
this.cleanup.setInterval(() => {
|
|
5357
1887
|
this.recordPeriodicTokenUsage();
|
|
5358
|
-
}, 5 * 60 * 1000);
|
|
1888
|
+
}, 5 * 60 * 1000, { description: 'periodic token recording' });
|
|
5359
1889
|
// Start subagent watcher for Claude Code background agent visibility (if enabled)
|
|
5360
1890
|
if (await this.isSubagentTrackingEnabled()) {
|
|
5361
1891
|
subagentWatcher.start();
|
|
@@ -5608,11 +2138,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5608
2138
|
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
|
5609
2139
|
// Set stopping flag to prevent new timer creation during shutdown
|
|
5610
2140
|
this._isStopping = true;
|
|
5611
|
-
//
|
|
5612
|
-
|
|
5613
|
-
clearInterval(this.sseHealthCheckTimer);
|
|
5614
|
-
this.sseHealthCheckTimer = null;
|
|
5615
|
-
}
|
|
2141
|
+
// Dispose all managed timers (intervals + resettable timeouts)
|
|
2142
|
+
this.cleanup.dispose();
|
|
5616
2143
|
// Gracefully close all SSE connections before clearing
|
|
5617
2144
|
for (const client of this.sseClients) {
|
|
5618
2145
|
try {
|
|
@@ -5633,38 +2160,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5633
2160
|
this.terminalBatchTimers.clear();
|
|
5634
2161
|
this.terminalBatches.clear();
|
|
5635
2162
|
this.terminalBatchSizes.clear();
|
|
5636
|
-
if (this.taskUpdateBatchTimer) {
|
|
5637
|
-
clearTimeout(this.taskUpdateBatchTimer);
|
|
5638
|
-
this.taskUpdateBatchTimer = null;
|
|
5639
|
-
}
|
|
5640
2163
|
this.taskUpdateBatches.clear();
|
|
5641
|
-
if (this.stateUpdateTimer) {
|
|
5642
|
-
clearTimeout(this.stateUpdateTimer);
|
|
5643
|
-
this.stateUpdateTimer = null;
|
|
5644
|
-
}
|
|
5645
2164
|
this.stateUpdatePending.clear();
|
|
5646
|
-
// Clear token recording timer
|
|
5647
|
-
if (this.tokenRecordingTimer) {
|
|
5648
|
-
clearInterval(this.tokenRecordingTimer);
|
|
5649
|
-
this.tokenRecordingTimer = null;
|
|
5650
|
-
}
|
|
5651
2165
|
this.lastRecordedTokens.clear();
|
|
5652
|
-
// Clear scheduled cleanup timer
|
|
5653
|
-
if (this.scheduledCleanupTimer) {
|
|
5654
|
-
clearInterval(this.scheduledCleanupTimer);
|
|
5655
|
-
this.scheduledCleanupTimer = null;
|
|
5656
|
-
}
|
|
5657
2166
|
// Stop multiplexer and flush pending saves
|
|
5658
2167
|
this.mux.destroy();
|
|
5659
2168
|
// Flush any pending persist-debounce timers and persist dirty sessions
|
|
5660
|
-
|
|
5661
|
-
clearTimeout(timer);
|
|
2169
|
+
this.persistDeb.flushAll((sessionId) => {
|
|
5662
2170
|
const session = this.sessions.get(sessionId);
|
|
5663
2171
|
if (session) {
|
|
5664
2172
|
this._persistSessionStateNow(session);
|
|
5665
2173
|
}
|
|
5666
|
-
}
|
|
5667
|
-
this.persistDebounceTimers.clear();
|
|
2174
|
+
});
|
|
5668
2175
|
// Clear cached state
|
|
5669
2176
|
this.cachedLightState = null;
|
|
5670
2177
|
this.cachedSessionsList = null;
|
|
@@ -5771,6 +2278,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
5771
2278
|
this.authFailures.dispose();
|
|
5772
2279
|
this.authFailures = null;
|
|
5773
2280
|
}
|
|
2281
|
+
if (this.qrAuthFailures) {
|
|
2282
|
+
this.qrAuthFailures.dispose();
|
|
2283
|
+
this.qrAuthFailures = null;
|
|
2284
|
+
}
|
|
5774
2285
|
this.activePlanOrchestrators.clear();
|
|
5775
2286
|
this.cleaningUp.clear();
|
|
5776
2287
|
// Dispose push store (flush pending saves)
|