aicodeman 0.2.8
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/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/ai-checker-base.d.ts +175 -0
- package/dist/ai-checker-base.d.ts.map +1 -0
- package/dist/ai-checker-base.js +424 -0
- package/dist/ai-checker-base.js.map +1 -0
- package/dist/ai-idle-checker.d.ts +53 -0
- package/dist/ai-idle-checker.d.ts.map +1 -0
- package/dist/ai-idle-checker.js +141 -0
- package/dist/ai-idle-checker.js.map +1 -0
- package/dist/ai-plan-checker.d.ts +52 -0
- package/dist/ai-plan-checker.d.ts.map +1 -0
- package/dist/ai-plan-checker.js +103 -0
- package/dist/ai-plan-checker.js.map +1 -0
- package/dist/bash-tool-parser.d.ts +191 -0
- package/dist/bash-tool-parser.d.ts.map +1 -0
- package/dist/bash-tool-parser.js +598 -0
- package/dist/bash-tool-parser.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +460 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +59 -0
- package/dist/config/buffer-limits.d.ts.map +1 -0
- package/dist/config/buffer-limits.js +74 -0
- package/dist/config/buffer-limits.js.map +1 -0
- package/dist/config/map-limits.d.ts +40 -0
- package/dist/config/map-limits.d.ts.map +1 -0
- package/dist/config/map-limits.js +52 -0
- package/dist/config/map-limits.js.map +1 -0
- package/dist/file-stream-manager.d.ts +148 -0
- package/dist/file-stream-manager.d.ts.map +1 -0
- package/dist/file-stream-manager.js +351 -0
- package/dist/file-stream-manager.js.map +1 -0
- package/dist/hooks-config.d.ts +31 -0
- package/dist/hooks-config.d.ts.map +1 -0
- package/dist/hooks-config.js +115 -0
- package/dist/hooks-config.js.map +1 -0
- package/dist/image-watcher.d.ts +86 -0
- package/dist/image-watcher.d.ts.map +1 -0
- package/dist/image-watcher.js +275 -0
- package/dist/image-watcher.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/mux-factory.d.ts +13 -0
- package/dist/mux-factory.d.ts.map +1 -0
- package/dist/mux-factory.js +19 -0
- package/dist/mux-factory.js.map +1 -0
- package/dist/mux-interface.d.ts +145 -0
- package/dist/mux-interface.d.ts.map +1 -0
- package/dist/mux-interface.js +9 -0
- package/dist/mux-interface.js.map +1 -0
- package/dist/plan-orchestrator.d.ts +123 -0
- package/dist/plan-orchestrator.d.ts.map +1 -0
- package/dist/plan-orchestrator.js +500 -0
- package/dist/plan-orchestrator.js.map +1 -0
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +9 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/planner.d.ts +14 -0
- package/dist/prompts/planner.d.ts.map +1 -0
- package/dist/prompts/planner.js +83 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/research-agent.d.ts +10 -0
- package/dist/prompts/research-agent.d.ts.map +1 -0
- package/dist/prompts/research-agent.js +143 -0
- package/dist/prompts/research-agent.js.map +1 -0
- package/dist/push-store.d.ts +41 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +168 -0
- package/dist/push-store.js.map +1 -0
- package/dist/ralph-config.d.ts +67 -0
- package/dist/ralph-config.d.ts.map +1 -0
- package/dist/ralph-config.js +134 -0
- package/dist/ralph-config.js.map +1 -0
- package/dist/ralph-loop.d.ts +124 -0
- package/dist/ralph-loop.d.ts.map +1 -0
- package/dist/ralph-loop.js +418 -0
- package/dist/ralph-loop.js.map +1 -0
- package/dist/ralph-tracker.d.ts +1081 -0
- package/dist/ralph-tracker.d.ts.map +1 -0
- package/dist/ralph-tracker.js +3343 -0
- package/dist/ralph-tracker.js.map +1 -0
- package/dist/respawn-controller.d.ts +1182 -0
- package/dist/respawn-controller.d.ts.map +1 -0
- package/dist/respawn-controller.js +2754 -0
- package/dist/respawn-controller.js.map +1 -0
- package/dist/run-summary.d.ts +123 -0
- package/dist/run-summary.d.ts.map +1 -0
- package/dist/run-summary.js +325 -0
- package/dist/run-summary.js.map +1 -0
- package/dist/session-lifecycle-log.d.ts +36 -0
- package/dist/session-lifecycle-log.d.ts.map +1 -0
- package/dist/session-lifecycle-log.js +101 -0
- package/dist/session-lifecycle-log.js.map +1 -0
- package/dist/session-manager.d.ts +97 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +224 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +686 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2025 -0
- package/dist/session.js.map +1 -0
- package/dist/state-store.d.ts +189 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +730 -0
- package/dist/state-store.js.map +1 -0
- package/dist/subagent-watcher.d.ts +345 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +1469 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/task-queue.d.ts +108 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +235 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/task-tracker.d.ts +306 -0
- package/dist/task-tracker.d.ts.map +1 -0
- package/dist/task-tracker.js +488 -0
- package/dist/task-tracker.js.map +1 -0
- package/dist/task.d.ts +73 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +177 -0
- package/dist/task.js.map +1 -0
- package/dist/team-watcher.d.ts +53 -0
- package/dist/team-watcher.d.ts.map +1 -0
- package/dist/team-watcher.js +313 -0
- package/dist/team-watcher.js.map +1 -0
- package/dist/templates/case-template.md +461 -0
- package/dist/templates/claude-md.d.ts +26 -0
- package/dist/templates/claude-md.d.ts.map +1 -0
- package/dist/templates/claude-md.js +74 -0
- package/dist/templates/claude-md.js.map +1 -0
- package/dist/tmux-manager.d.ts +181 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +1405 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/transcript-watcher.d.ts +110 -0
- package/dist/transcript-watcher.d.ts.map +1 -0
- package/dist/transcript-watcher.js +338 -0
- package/dist/transcript-watcher.js.map +1 -0
- package/dist/tunnel-manager.d.ts +54 -0
- package/dist/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel-manager.js +251 -0
- package/dist/tunnel-manager.js.map +1 -0
- package/dist/types.d.ts +1139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/buffer-accumulator.d.ts +111 -0
- package/dist/utils/buffer-accumulator.d.ts.map +1 -0
- package/dist/utils/buffer-accumulator.js +172 -0
- package/dist/utils/buffer-accumulator.js.map +1 -0
- package/dist/utils/claude-cli-resolver.d.ts +26 -0
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
- package/dist/utils/claude-cli-resolver.js +78 -0
- package/dist/utils/claude-cli-resolver.js.map +1 -0
- package/dist/utils/cleanup-manager.d.ts +165 -0
- package/dist/utils/cleanup-manager.d.ts.map +1 -0
- package/dist/utils/cleanup-manager.js +274 -0
- package/dist/utils/cleanup-manager.js.map +1 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/lru-map.d.ts +140 -0
- package/dist/utils/lru-map.d.ts.map +1 -0
- package/dist/utils/lru-map.js +234 -0
- package/dist/utils/lru-map.js.map +1 -0
- package/dist/utils/nice-wrapper.d.ts +13 -0
- package/dist/utils/nice-wrapper.d.ts.map +1 -0
- package/dist/utils/nice-wrapper.js +17 -0
- package/dist/utils/nice-wrapper.js.map +1 -0
- package/dist/utils/opencode-cli-resolver.d.ts +21 -0
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
- package/dist/utils/opencode-cli-resolver.js +67 -0
- package/dist/utils/opencode-cli-resolver.js.map +1 -0
- package/dist/utils/regex-patterns.d.ts +64 -0
- package/dist/utils/regex-patterns.d.ts.map +1 -0
- package/dist/utils/regex-patterns.js +74 -0
- package/dist/utils/regex-patterns.js.map +1 -0
- package/dist/utils/stale-expiration-map.d.ts +159 -0
- package/dist/utils/stale-expiration-map.d.ts.map +1 -0
- package/dist/utils/stale-expiration-map.js +277 -0
- package/dist/utils/stale-expiration-map.js.map +1 -0
- package/dist/utils/string-similarity.d.ts +108 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +189 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/dist/utils/token-validation.d.ts +39 -0
- package/dist/utils/token-validation.d.ts.map +1 -0
- package/dist/utils/token-validation.js +59 -0
- package/dist/utils/token-validation.js.map +1 -0
- package/dist/utils/type-safety.d.ts +33 -0
- package/dist/utils/type-safety.d.ts.map +1 -0
- package/dist/utils/type-safety.js +35 -0
- package/dist/utils/type-safety.js.map +1 -0
- package/dist/web/public/app.js +491 -0
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/index.html +1675 -0
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.json +8 -0
- package/dist/web/public/mobile.css +1 -0
- package/dist/web/public/mobile.css.br +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/ralph-wizard.js +1037 -0
- 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 +1 -0
- package/dist/web/public/styles.css.br +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/sw.js +67 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html +155 -0
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.br +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 +1 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +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 +2 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css +209 -0
- package/dist/web/public/vendor/xterm.css.br +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js +9 -0
- package/dist/web/public/vendor/xterm.min.js.br +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/schemas.d.ts +479 -0
- package/dist/web/schemas.d.ts.map +1 -0
- package/dist/web/schemas.js +448 -0
- package/dist/web/schemas.js.map +1 -0
- package/dist/web/server.d.ts +207 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +5784 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +110 -0
- package/scripts/postinstall.js +390 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,2025 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Core PTY session wrapper for Claude CLI interactions.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the Session class which manages a PTY (pseudo-terminal)
|
|
5
|
+
* process running the Claude CLI. It supports three operation modes:
|
|
6
|
+
*
|
|
7
|
+
* 1. **One-shot mode** (`runPrompt`): Execute a single prompt and get JSON response
|
|
8
|
+
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
|
|
9
|
+
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
|
|
10
|
+
*
|
|
11
|
+
* The session can optionally run inside a tmux session for persistence across disconnects.
|
|
12
|
+
* It tracks tokens, costs, background tasks, and supports
|
|
13
|
+
* auto-clear/auto-compact functionality when token limits are approached.
|
|
14
|
+
*
|
|
15
|
+
* @module session
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from 'node:events';
|
|
18
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
19
|
+
import * as pty from 'node-pty';
|
|
20
|
+
import { DEFAULT_NICE_CONFIG, } from './types.js';
|
|
21
|
+
import { TaskTracker } from './task-tracker.js';
|
|
22
|
+
import { RalphTracker } from './ralph-tracker.js';
|
|
23
|
+
import { BashToolParser } from './bash-tool-parser.js';
|
|
24
|
+
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
|
25
|
+
import { LRUMap } from './utils/lru-map.js';
|
|
26
|
+
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
|
27
|
+
import { MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, MAX_TEXT_OUTPUT_SIZE, TRIM_TEXT_TO as TEXT_OUTPUT_TRIM_SIZE, MAX_MESSAGES, MAX_LINE_BUFFER_SIZE, } from './config/buffer-limits.js';
|
|
28
|
+
/** Line buffer flush interval (100ms) - forces processing of partial lines */
|
|
29
|
+
const LINE_BUFFER_FLUSH_INTERVAL = 100;
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Timing Constants
|
|
32
|
+
// ============================================================================
|
|
33
|
+
/** Delay after mux session creation before sending commands (300ms) */
|
|
34
|
+
const MUX_STARTUP_DELAY_MS = 300;
|
|
35
|
+
/** Delay before declaring session idle after last output (2 seconds) */
|
|
36
|
+
const IDLE_DETECTION_DELAY_MS = 2000;
|
|
37
|
+
/** Delay for auto-compact/clear retry attempts (2 seconds) */
|
|
38
|
+
const AUTO_RETRY_DELAY_MS = 2000;
|
|
39
|
+
/** Delay for auto-compact/clear initial check (1 second) */
|
|
40
|
+
const AUTO_INITIAL_DELAY_MS = 1000;
|
|
41
|
+
/** Graceful shutdown delay when stopping session (100ms) */
|
|
42
|
+
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
|
43
|
+
// Filter out terminal focus escape sequences (focus in/out reports)
|
|
44
|
+
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
|
47
|
+
// Pattern to match Task tool invocations in terminal output
|
|
48
|
+
// Matches: "Explore(Description)", "Task(Description)", "Bash(Description)", etc.
|
|
49
|
+
// The prefix characters vary (●, ·, ✶, etc.) so we don't require them
|
|
50
|
+
// We look for the tool name followed by (description)
|
|
51
|
+
const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)/g;
|
|
52
|
+
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
|
53
|
+
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
|
56
|
+
/** Pattern to match Ctrl+L (form feed) characters */
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
const CTRL_L_PATTERN = /\x0c/g;
|
|
59
|
+
/** Pattern to split by newlines (CR or LF) */
|
|
60
|
+
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
|
61
|
+
// Claude CLI PATH resolution — shared utility
|
|
62
|
+
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
|
63
|
+
/**
|
|
64
|
+
* Core session class that wraps a PTY process running Claude CLI or a shell.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* // Create and start an interactive Claude session
|
|
69
|
+
* const session = new Session({
|
|
70
|
+
* workingDir: '/path/to/project',
|
|
71
|
+
* mux: muxManager,
|
|
72
|
+
* useMux: true
|
|
73
|
+
* });
|
|
74
|
+
* await session.startInteractive();
|
|
75
|
+
*
|
|
76
|
+
* // Listen for events
|
|
77
|
+
* session.on('terminal', (data) => console.log(data));
|
|
78
|
+
* session.on('message', (msg) => console.log('Claude:', msg));
|
|
79
|
+
*
|
|
80
|
+
* // Send input
|
|
81
|
+
* session.write('Hello Claude!\r');
|
|
82
|
+
*
|
|
83
|
+
* // Stop when done
|
|
84
|
+
* await session.stop();
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @fires Session#terminal - Raw terminal output
|
|
88
|
+
* @fires Session#message - Parsed Claude JSON message
|
|
89
|
+
* @fires Session#completion - One-shot prompt completed
|
|
90
|
+
* @fires Session#exit - Process exited
|
|
91
|
+
* @fires Session#autoClear - Token threshold reached, clearing context
|
|
92
|
+
* @fires Session#autoCompact - Token threshold reached, compacting context
|
|
93
|
+
*/
|
|
94
|
+
export class Session extends EventEmitter {
|
|
95
|
+
id;
|
|
96
|
+
workingDir;
|
|
97
|
+
createdAt;
|
|
98
|
+
mode;
|
|
99
|
+
/** Maximum number of task descriptions to keep (LRUMap handles size limit automatically) */
|
|
100
|
+
static MAX_TASK_DESCRIPTIONS = 100;
|
|
101
|
+
static TASK_DESCRIPTION_MAX_AGE_MS = 30000; // Keep descriptions for 30 seconds
|
|
102
|
+
_name;
|
|
103
|
+
ptyProcess = null;
|
|
104
|
+
_pid = null;
|
|
105
|
+
_status = 'idle';
|
|
106
|
+
_currentTaskId = null;
|
|
107
|
+
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
|
|
108
|
+
_terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
|
|
109
|
+
_textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
|
110
|
+
_errorBuffer = '';
|
|
111
|
+
_lastActivityAt;
|
|
112
|
+
_claudeSessionId = null;
|
|
113
|
+
_totalCost = 0;
|
|
114
|
+
_messages = [];
|
|
115
|
+
_lineBuffer = '';
|
|
116
|
+
_lineBufferFlushTimer = null;
|
|
117
|
+
resolvePromise = null;
|
|
118
|
+
rejectPromise = null;
|
|
119
|
+
_promptResolved = false; // Guard against race conditions in runPrompt
|
|
120
|
+
_isWorking = false;
|
|
121
|
+
_lastPromptTime = 0;
|
|
122
|
+
activityTimeout = null;
|
|
123
|
+
_awaitingIdleConfirmation = false; // Prevents timeout reset during idle detection
|
|
124
|
+
_taskTracker;
|
|
125
|
+
// Token tracking for auto-clear
|
|
126
|
+
_totalInputTokens = 0;
|
|
127
|
+
_totalOutputTokens = 0;
|
|
128
|
+
_autoClearThreshold = 140000; // Default 140k tokens
|
|
129
|
+
_autoClearEnabled = false;
|
|
130
|
+
_isClearing = false; // Prevent recursive clearing
|
|
131
|
+
// Auto-compact settings
|
|
132
|
+
_autoCompactThreshold = 110000; // Default 110k tokens (lower than clear)
|
|
133
|
+
_autoCompactEnabled = false;
|
|
134
|
+
_autoCompactPrompt = ''; // Optional prompt for compact
|
|
135
|
+
_isCompacting = false; // Prevent recursive compacting
|
|
136
|
+
// Image watcher setting (per-session toggle)
|
|
137
|
+
_imageWatcherEnabled = false;
|
|
138
|
+
// Flicker filter setting (per-session toggle, applied on frontend)
|
|
139
|
+
_flickerFilterEnabled = false;
|
|
140
|
+
// Claude Code CLI info (parsed from terminal startup)
|
|
141
|
+
_cliVersion = '';
|
|
142
|
+
_cliModel = '';
|
|
143
|
+
_cliAccountType = '';
|
|
144
|
+
_cliLatestVersion = '';
|
|
145
|
+
_cliInfoParsed = false; // Only parse once per session
|
|
146
|
+
// Timer tracking for cleanup (prevents memory leaks)
|
|
147
|
+
_autoCompactTimer = null;
|
|
148
|
+
_autoClearTimer = null;
|
|
149
|
+
_promptCheckInterval = null;
|
|
150
|
+
_promptCheckTimeout = null;
|
|
151
|
+
_shellIdleTimer = null;
|
|
152
|
+
// Multiplexer session support (tmux)
|
|
153
|
+
_mux = null;
|
|
154
|
+
_muxSession = null;
|
|
155
|
+
_useMux = false;
|
|
156
|
+
// Flag to prevent new timers after session is stopped
|
|
157
|
+
_isStopped = false;
|
|
158
|
+
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
|
159
|
+
_ralphTracker;
|
|
160
|
+
// Agent tree tracking
|
|
161
|
+
_parentAgentId = null;
|
|
162
|
+
_childAgentIds = [];
|
|
163
|
+
// Nice prioritying configuration
|
|
164
|
+
_niceConfig = { ...DEFAULT_NICE_CONFIG };
|
|
165
|
+
// Claude model override (e.g., 'opus', 'sonnet', 'haiku')
|
|
166
|
+
_model;
|
|
167
|
+
// Claude CLI startup permission mode
|
|
168
|
+
_claudeMode = 'dangerously-skip-permissions';
|
|
169
|
+
_allowedTools;
|
|
170
|
+
// OpenCode configuration (only for mode === 'opencode')
|
|
171
|
+
_openCodeConfig;
|
|
172
|
+
// Session color for visual differentiation
|
|
173
|
+
_color = 'default';
|
|
174
|
+
// Store handler references for cleanup (prevents memory leaks)
|
|
175
|
+
_taskTrackerHandlers = null;
|
|
176
|
+
_ralphHandlers = null;
|
|
177
|
+
// Bash tool tracking (file paths for live log viewing)
|
|
178
|
+
_bashToolParser;
|
|
179
|
+
_bashToolHandlers = null;
|
|
180
|
+
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
|
181
|
+
// Used to correlate with SubagentWatcher discoveries for better window titles
|
|
182
|
+
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
|
183
|
+
_recentTaskDescriptions = new LRUMap({
|
|
184
|
+
maxSize: Session.MAX_TASK_DESCRIPTIONS,
|
|
185
|
+
});
|
|
186
|
+
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
|
187
|
+
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
|
188
|
+
_lastExpensiveProcessTime = 0;
|
|
189
|
+
_pendingCleanData = '';
|
|
190
|
+
_expensiveProcessTimer = null;
|
|
191
|
+
static EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
|
192
|
+
constructor(config) {
|
|
193
|
+
super();
|
|
194
|
+
this.setMaxListeners(25);
|
|
195
|
+
// Default error handler prevents unhandled 'error' events from crashing the process.
|
|
196
|
+
// Server attaches its own handler after construction — this is a safety net for the gap.
|
|
197
|
+
this.on('error', (err) => {
|
|
198
|
+
console.error(`[Session] Unhandled error event:`, err);
|
|
199
|
+
});
|
|
200
|
+
this.id = config.id || uuidv4();
|
|
201
|
+
this.workingDir = config.workingDir;
|
|
202
|
+
this.createdAt = config.createdAt || Date.now();
|
|
203
|
+
this.mode = config.mode || 'claude';
|
|
204
|
+
this._name = config.name || '';
|
|
205
|
+
this._lastActivityAt = this.createdAt;
|
|
206
|
+
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id}
|
|
207
|
+
// to Claude CLI, so the Claude session ID always matches the Codeman session ID.
|
|
208
|
+
// This ensures subagent matching works even for recovered sessions (where
|
|
209
|
+
// startInteractive() hasn't been called yet).
|
|
210
|
+
this._claudeSessionId = this.id;
|
|
211
|
+
this._mux = config.mux || null;
|
|
212
|
+
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
|
213
|
+
this._muxSession = config.muxSession || null;
|
|
214
|
+
// Apply Nice priority configuration if provided
|
|
215
|
+
if (config.niceConfig) {
|
|
216
|
+
this._niceConfig = { ...config.niceConfig };
|
|
217
|
+
}
|
|
218
|
+
// Apply model override if provided
|
|
219
|
+
if (config.model) {
|
|
220
|
+
this._model = config.model;
|
|
221
|
+
}
|
|
222
|
+
// Apply Claude CLI permission mode
|
|
223
|
+
if (config.claudeMode) {
|
|
224
|
+
this._claudeMode = config.claudeMode;
|
|
225
|
+
}
|
|
226
|
+
if (config.allowedTools) {
|
|
227
|
+
this._allowedTools = config.allowedTools;
|
|
228
|
+
}
|
|
229
|
+
// Apply OpenCode configuration
|
|
230
|
+
if (config.openCodeConfig) {
|
|
231
|
+
this._openCodeConfig = config.openCodeConfig;
|
|
232
|
+
}
|
|
233
|
+
// Initialize task tracker and forward events (store handlers for cleanup)
|
|
234
|
+
this._taskTracker = new TaskTracker();
|
|
235
|
+
this._taskTrackerHandlers = {
|
|
236
|
+
taskCreated: (task) => this.emit('taskCreated', task),
|
|
237
|
+
taskUpdated: (task) => this.emit('taskUpdated', task),
|
|
238
|
+
taskCompleted: (task) => this.emit('taskCompleted', task),
|
|
239
|
+
taskFailed: (task, error) => this.emit('taskFailed', task, error),
|
|
240
|
+
};
|
|
241
|
+
this._taskTracker.on('taskCreated', this._taskTrackerHandlers.taskCreated);
|
|
242
|
+
this._taskTracker.on('taskUpdated', this._taskTrackerHandlers.taskUpdated);
|
|
243
|
+
this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted);
|
|
244
|
+
this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed);
|
|
245
|
+
// Initialize Ralph tracker and forward events (store handlers for cleanup)
|
|
246
|
+
this._ralphTracker = new RalphTracker();
|
|
247
|
+
this._ralphHandlers = {
|
|
248
|
+
loopUpdate: (state) => this.emit('ralphLoopUpdate', state),
|
|
249
|
+
todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos),
|
|
250
|
+
completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase),
|
|
251
|
+
statusBlockDetected: (block) => this.emit('ralphStatusBlockDetected', block),
|
|
252
|
+
circuitBreakerUpdate: (status) => this.emit('ralphCircuitBreakerUpdate', status),
|
|
253
|
+
exitGateMet: (data) => this.emit('ralphExitGateMet', data),
|
|
254
|
+
};
|
|
255
|
+
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
|
|
256
|
+
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
|
|
257
|
+
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
|
|
258
|
+
this._ralphTracker.on('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
|
|
259
|
+
this._ralphTracker.on('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
|
|
260
|
+
this._ralphTracker.on('exitGateMet', this._ralphHandlers.exitGateMet);
|
|
261
|
+
// Initialize Bash tool parser and forward events (store handlers for cleanup)
|
|
262
|
+
this._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir });
|
|
263
|
+
this._bashToolHandlers = {
|
|
264
|
+
toolStart: (tool) => this.emit('bashToolStart', tool),
|
|
265
|
+
toolEnd: (tool) => this.emit('bashToolEnd', tool),
|
|
266
|
+
toolsUpdate: (tools) => this.emit('bashToolsUpdate', tools),
|
|
267
|
+
};
|
|
268
|
+
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
|
269
|
+
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
|
270
|
+
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
|
271
|
+
}
|
|
272
|
+
get status() {
|
|
273
|
+
return this._status;
|
|
274
|
+
}
|
|
275
|
+
get currentTaskId() {
|
|
276
|
+
return this._currentTaskId;
|
|
277
|
+
}
|
|
278
|
+
get pid() {
|
|
279
|
+
return this._pid;
|
|
280
|
+
}
|
|
281
|
+
get terminalBuffer() {
|
|
282
|
+
return this._terminalBuffer.value;
|
|
283
|
+
}
|
|
284
|
+
get terminalBufferLength() {
|
|
285
|
+
return this._terminalBuffer.length;
|
|
286
|
+
}
|
|
287
|
+
get textOutput() {
|
|
288
|
+
return this._textOutput.value;
|
|
289
|
+
}
|
|
290
|
+
get errorBuffer() {
|
|
291
|
+
return this._errorBuffer;
|
|
292
|
+
}
|
|
293
|
+
get lastActivityAt() {
|
|
294
|
+
return this._lastActivityAt;
|
|
295
|
+
}
|
|
296
|
+
get claudeSessionId() {
|
|
297
|
+
return this._claudeSessionId;
|
|
298
|
+
}
|
|
299
|
+
get totalCost() {
|
|
300
|
+
return this._totalCost;
|
|
301
|
+
}
|
|
302
|
+
get messages() {
|
|
303
|
+
return this._messages;
|
|
304
|
+
}
|
|
305
|
+
get isWorking() {
|
|
306
|
+
return this._isWorking;
|
|
307
|
+
}
|
|
308
|
+
get lastPromptTime() {
|
|
309
|
+
return this._lastPromptTime;
|
|
310
|
+
}
|
|
311
|
+
get taskTracker() {
|
|
312
|
+
return this._taskTracker;
|
|
313
|
+
}
|
|
314
|
+
get runningTaskCount() {
|
|
315
|
+
return this._taskTracker.getRunningCount();
|
|
316
|
+
}
|
|
317
|
+
get taskTree() {
|
|
318
|
+
return this._taskTracker.getTaskTree();
|
|
319
|
+
}
|
|
320
|
+
get taskStats() {
|
|
321
|
+
return this._taskTracker.getStats();
|
|
322
|
+
}
|
|
323
|
+
// Ralph tracking getters
|
|
324
|
+
get ralphTracker() {
|
|
325
|
+
return this._ralphTracker;
|
|
326
|
+
}
|
|
327
|
+
get ralphLoopState() {
|
|
328
|
+
return this._ralphTracker.loopState;
|
|
329
|
+
}
|
|
330
|
+
get ralphTodos() {
|
|
331
|
+
return this._ralphTracker.todos;
|
|
332
|
+
}
|
|
333
|
+
get ralphTodoStats() {
|
|
334
|
+
return this._ralphTracker.getTodoStats();
|
|
335
|
+
}
|
|
336
|
+
// Bash tool tracking getters
|
|
337
|
+
get bashToolParser() {
|
|
338
|
+
return this._bashToolParser;
|
|
339
|
+
}
|
|
340
|
+
get activeTools() {
|
|
341
|
+
return this._bashToolParser.activeTools;
|
|
342
|
+
}
|
|
343
|
+
get parentAgentId() {
|
|
344
|
+
return this._parentAgentId;
|
|
345
|
+
}
|
|
346
|
+
set parentAgentId(value) {
|
|
347
|
+
this._parentAgentId = value;
|
|
348
|
+
}
|
|
349
|
+
get childAgentIds() {
|
|
350
|
+
return [...this._childAgentIds];
|
|
351
|
+
}
|
|
352
|
+
addChildAgentId(agentId) {
|
|
353
|
+
if (!this._childAgentIds.includes(agentId)) {
|
|
354
|
+
this._childAgentIds.push(agentId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
removeChildAgentId(agentId) {
|
|
358
|
+
const idx = this._childAgentIds.indexOf(agentId);
|
|
359
|
+
if (idx >= 0)
|
|
360
|
+
this._childAgentIds.splice(idx, 1);
|
|
361
|
+
}
|
|
362
|
+
// Nice priority config getters and setters
|
|
363
|
+
get niceConfig() {
|
|
364
|
+
return { ...this._niceConfig };
|
|
365
|
+
}
|
|
366
|
+
/** Claude CLI startup permission mode */
|
|
367
|
+
get claudeMode() {
|
|
368
|
+
return this._claudeMode;
|
|
369
|
+
}
|
|
370
|
+
/** Allowed tools list (for 'allowedTools' mode) */
|
|
371
|
+
get allowedTools() {
|
|
372
|
+
return this._allowedTools;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Build Claude CLI permission flags based on the configured mode.
|
|
376
|
+
* Returns an array of args to pass to the CLI.
|
|
377
|
+
*/
|
|
378
|
+
_buildPermissionArgs() {
|
|
379
|
+
switch (this._claudeMode) {
|
|
380
|
+
case 'dangerously-skip-permissions':
|
|
381
|
+
return ['--dangerously-skip-permissions'];
|
|
382
|
+
case 'allowedTools':
|
|
383
|
+
if (this._allowedTools) {
|
|
384
|
+
return ['--allowedTools', this._allowedTools];
|
|
385
|
+
}
|
|
386
|
+
// Fall back to normal mode if no tools specified
|
|
387
|
+
return [];
|
|
388
|
+
case 'normal':
|
|
389
|
+
default:
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Set CPU priority configuration.
|
|
395
|
+
* Note: This only affects new sessions; existing running processes won't be changed.
|
|
396
|
+
*/
|
|
397
|
+
setNice(config) {
|
|
398
|
+
if (config.enabled !== undefined) {
|
|
399
|
+
this._niceConfig.enabled = config.enabled;
|
|
400
|
+
}
|
|
401
|
+
if (config.niceValue !== undefined) {
|
|
402
|
+
// Clamp to valid range
|
|
403
|
+
this._niceConfig.niceValue = Math.max(-20, Math.min(19, config.niceValue));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Session color for visual differentiation
|
|
407
|
+
get color() {
|
|
408
|
+
return this._color;
|
|
409
|
+
}
|
|
410
|
+
setColor(color) {
|
|
411
|
+
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
|
412
|
+
if (validColors.includes(color)) {
|
|
413
|
+
this._color = color;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Token tracking getters and setters
|
|
417
|
+
get totalTokens() {
|
|
418
|
+
return this._totalInputTokens + this._totalOutputTokens;
|
|
419
|
+
}
|
|
420
|
+
get inputTokens() {
|
|
421
|
+
return this._totalInputTokens;
|
|
422
|
+
}
|
|
423
|
+
get outputTokens() {
|
|
424
|
+
return this._totalOutputTokens;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Restore token and cost values from saved state.
|
|
428
|
+
* Called when recovering sessions after server restart.
|
|
429
|
+
*/
|
|
430
|
+
restoreTokens(inputTokens, outputTokens, totalCost) {
|
|
431
|
+
// Sanity check: reject absurdly large individual values
|
|
432
|
+
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
|
433
|
+
console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
|
437
|
+
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
|
438
|
+
console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// Reject negative values
|
|
442
|
+
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
|
443
|
+
console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this._totalInputTokens = inputTokens;
|
|
447
|
+
this._totalOutputTokens = outputTokens;
|
|
448
|
+
this._totalCost = totalCost;
|
|
449
|
+
}
|
|
450
|
+
get autoClearThreshold() {
|
|
451
|
+
return this._autoClearThreshold;
|
|
452
|
+
}
|
|
453
|
+
get autoClearEnabled() {
|
|
454
|
+
return this._autoClearEnabled;
|
|
455
|
+
}
|
|
456
|
+
get name() {
|
|
457
|
+
return this._name;
|
|
458
|
+
}
|
|
459
|
+
set name(value) {
|
|
460
|
+
this._name = value;
|
|
461
|
+
}
|
|
462
|
+
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
|
463
|
+
static MIN_AUTO_THRESHOLD = 1000;
|
|
464
|
+
/** Maximum valid threshold for auto-clear/compact (500k tokens) */
|
|
465
|
+
static MAX_AUTO_THRESHOLD = 500_000;
|
|
466
|
+
/** Default auto-clear threshold when invalid value provided */
|
|
467
|
+
static DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
|
|
468
|
+
/** Default auto-compact threshold when invalid value provided */
|
|
469
|
+
static DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
|
470
|
+
setAutoClear(enabled, threshold) {
|
|
471
|
+
this._autoClearEnabled = enabled;
|
|
472
|
+
if (threshold !== undefined) {
|
|
473
|
+
// Validate threshold bounds
|
|
474
|
+
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
|
475
|
+
console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`);
|
|
476
|
+
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
this._autoClearThreshold = threshold;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
get autoCompactThreshold() {
|
|
484
|
+
return this._autoCompactThreshold;
|
|
485
|
+
}
|
|
486
|
+
get autoCompactEnabled() {
|
|
487
|
+
return this._autoCompactEnabled;
|
|
488
|
+
}
|
|
489
|
+
get autoCompactPrompt() {
|
|
490
|
+
return this._autoCompactPrompt;
|
|
491
|
+
}
|
|
492
|
+
setAutoCompact(enabled, threshold, prompt) {
|
|
493
|
+
this._autoCompactEnabled = enabled;
|
|
494
|
+
if (threshold !== undefined) {
|
|
495
|
+
// Validate threshold bounds
|
|
496
|
+
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
|
497
|
+
console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`);
|
|
498
|
+
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
this._autoCompactThreshold = threshold;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (prompt !== undefined) {
|
|
505
|
+
this._autoCompactPrompt = prompt;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
get imageWatcherEnabled() {
|
|
509
|
+
return this._imageWatcherEnabled;
|
|
510
|
+
}
|
|
511
|
+
set imageWatcherEnabled(enabled) {
|
|
512
|
+
this._imageWatcherEnabled = enabled;
|
|
513
|
+
}
|
|
514
|
+
get flickerFilterEnabled() {
|
|
515
|
+
return this._flickerFilterEnabled;
|
|
516
|
+
}
|
|
517
|
+
set flickerFilterEnabled(enabled) {
|
|
518
|
+
this._flickerFilterEnabled = enabled;
|
|
519
|
+
}
|
|
520
|
+
isIdle() {
|
|
521
|
+
return this._status === 'idle';
|
|
522
|
+
}
|
|
523
|
+
isBusy() {
|
|
524
|
+
return this._status === 'busy';
|
|
525
|
+
}
|
|
526
|
+
isRunning() {
|
|
527
|
+
return this._status === 'idle' || this._status === 'busy';
|
|
528
|
+
}
|
|
529
|
+
toState() {
|
|
530
|
+
return {
|
|
531
|
+
id: this.id,
|
|
532
|
+
pid: this.pid,
|
|
533
|
+
status: this._status,
|
|
534
|
+
workingDir: this.workingDir,
|
|
535
|
+
currentTaskId: this._currentTaskId,
|
|
536
|
+
createdAt: this.createdAt,
|
|
537
|
+
lastActivityAt: this._lastActivityAt,
|
|
538
|
+
name: this._name,
|
|
539
|
+
mode: this.mode,
|
|
540
|
+
autoClearEnabled: this._autoClearEnabled,
|
|
541
|
+
autoClearThreshold: this._autoClearThreshold,
|
|
542
|
+
autoCompactEnabled: this._autoCompactEnabled,
|
|
543
|
+
autoCompactThreshold: this._autoCompactThreshold,
|
|
544
|
+
autoCompactPrompt: this._autoCompactPrompt,
|
|
545
|
+
imageWatcherEnabled: this._imageWatcherEnabled,
|
|
546
|
+
totalCost: this._totalCost,
|
|
547
|
+
inputTokens: this._totalInputTokens,
|
|
548
|
+
outputTokens: this._totalOutputTokens,
|
|
549
|
+
ralphEnabled: this._ralphTracker.enabled,
|
|
550
|
+
ralphAutoEnableDisabled: this._ralphTracker.autoEnableDisabled || undefined,
|
|
551
|
+
ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined,
|
|
552
|
+
parentAgentId: this._parentAgentId || undefined,
|
|
553
|
+
childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined,
|
|
554
|
+
niceEnabled: this._niceConfig.enabled,
|
|
555
|
+
niceValue: this._niceConfig.niceValue,
|
|
556
|
+
color: this._color,
|
|
557
|
+
flickerFilterEnabled: this._flickerFilterEnabled,
|
|
558
|
+
cliVersion: this._cliVersion || undefined,
|
|
559
|
+
cliModel: this._cliModel || undefined,
|
|
560
|
+
cliAccountType: this._cliAccountType || undefined,
|
|
561
|
+
cliLatestVersion: this._cliLatestVersion || undefined,
|
|
562
|
+
openCodeConfig: this._openCodeConfig,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
toDetailedState() {
|
|
566
|
+
return {
|
|
567
|
+
...this.toLightDetailedState(),
|
|
568
|
+
textOutput: this._textOutput.value,
|
|
569
|
+
terminalBuffer: this._terminalBuffer.value,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Lightweight detailed state that excludes heavy buffers (textOutput, terminalBuffer).
|
|
574
|
+
* Use for SSE session:updated broadcasts where buffers aren't needed.
|
|
575
|
+
* Full buffers are fetched on-demand via /api/sessions/:id/terminal.
|
|
576
|
+
*/
|
|
577
|
+
toLightDetailedState() {
|
|
578
|
+
return {
|
|
579
|
+
...this.toState(),
|
|
580
|
+
name: this._name,
|
|
581
|
+
mode: this.mode,
|
|
582
|
+
claudeSessionId: this._claudeSessionId,
|
|
583
|
+
totalCost: this._totalCost,
|
|
584
|
+
messageCount: this._messages.length,
|
|
585
|
+
isWorking: this._isWorking,
|
|
586
|
+
lastPromptTime: this._lastPromptTime,
|
|
587
|
+
// Buffer statistics for monitoring long-running sessions
|
|
588
|
+
bufferStats: {
|
|
589
|
+
terminalBufferSize: this._terminalBuffer.length,
|
|
590
|
+
textOutputSize: this._textOutput.length,
|
|
591
|
+
messageCount: this._messages.length,
|
|
592
|
+
maxTerminalBuffer: MAX_TERMINAL_BUFFER_SIZE,
|
|
593
|
+
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
|
|
594
|
+
maxMessages: MAX_MESSAGES,
|
|
595
|
+
},
|
|
596
|
+
// Background task tracking (light tree strips large output strings)
|
|
597
|
+
taskStats: this._taskTracker.getStats(),
|
|
598
|
+
taskTree: this._taskTracker.getTaskTreeLight(),
|
|
599
|
+
// Token tracking
|
|
600
|
+
tokens: {
|
|
601
|
+
input: this._totalInputTokens,
|
|
602
|
+
output: this._totalOutputTokens,
|
|
603
|
+
total: this._totalInputTokens + this._totalOutputTokens,
|
|
604
|
+
},
|
|
605
|
+
autoClear: {
|
|
606
|
+
enabled: this._autoClearEnabled,
|
|
607
|
+
threshold: this._autoClearThreshold,
|
|
608
|
+
},
|
|
609
|
+
// CPU priority configuration
|
|
610
|
+
nice: {
|
|
611
|
+
enabled: this._niceConfig.enabled,
|
|
612
|
+
niceValue: this._niceConfig.niceValue,
|
|
613
|
+
},
|
|
614
|
+
// Ralph tracking state
|
|
615
|
+
ralphLoop: this._ralphTracker.loopState,
|
|
616
|
+
ralphTodos: this._ralphTracker.todos,
|
|
617
|
+
ralphTodoStats: this._ralphTracker.getTodoStats(),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Starts an interactive Claude CLI session with full terminal support.
|
|
622
|
+
*
|
|
623
|
+
* This spawns Claude CLI in interactive mode with the configured permission
|
|
624
|
+
* mode (default: `--dangerously-skip-permissions`). If mux wrapping is enabled,
|
|
625
|
+
* the session runs inside a tmux session for persistence across disconnects.
|
|
626
|
+
*
|
|
627
|
+
* @throws {Error} If a process is already running in this session
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```typescript
|
|
631
|
+
* const session = new Session({ workingDir: '/project', useMux: true });
|
|
632
|
+
* await session.startInteractive();
|
|
633
|
+
* session.on('terminal', (data) => process.stdout.write(data));
|
|
634
|
+
* session.write('help me with this code\r');
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
async startInteractive() {
|
|
638
|
+
if (this.ptyProcess) {
|
|
639
|
+
throw new Error('Session already has a running process');
|
|
640
|
+
}
|
|
641
|
+
this._status = 'busy';
|
|
642
|
+
this._terminalBuffer.clear();
|
|
643
|
+
this._textOutput.clear();
|
|
644
|
+
this._errorBuffer = '';
|
|
645
|
+
this._messages = [];
|
|
646
|
+
this._lineBuffer = '';
|
|
647
|
+
this._lastActivityAt = Date.now();
|
|
648
|
+
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
|
649
|
+
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux.backend})` : ''));
|
|
650
|
+
// If mux wrapping is enabled, create or attach to a mux session
|
|
651
|
+
if (this._useMux && this._mux) {
|
|
652
|
+
try {
|
|
653
|
+
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
|
654
|
+
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
|
655
|
+
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
|
656
|
+
this._muxSession = null;
|
|
657
|
+
}
|
|
658
|
+
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
|
659
|
+
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
|
660
|
+
let needsNewSession = false;
|
|
661
|
+
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
|
662
|
+
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
|
663
|
+
const newPid = await this._mux.respawnPane({
|
|
664
|
+
sessionId: this.id,
|
|
665
|
+
workingDir: this.workingDir,
|
|
666
|
+
mode: this.mode,
|
|
667
|
+
niceConfig: this._niceConfig,
|
|
668
|
+
model: this._model,
|
|
669
|
+
claudeMode: this._claudeMode,
|
|
670
|
+
allowedTools: this._allowedTools,
|
|
671
|
+
openCodeConfig: this._openCodeConfig,
|
|
672
|
+
});
|
|
673
|
+
if (!newPid) {
|
|
674
|
+
console.error('[Session] Failed to respawn pane, will create new session');
|
|
675
|
+
needsNewSession = true;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Wait a moment for the respawned process to fully start
|
|
679
|
+
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Check if we already have a mux session (restored session)
|
|
683
|
+
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
|
684
|
+
if (isRestoredSession) {
|
|
685
|
+
console.log('[Session] Attaching to existing mux session:', this._muxSession.muxName);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// Create a new mux session
|
|
689
|
+
this._muxSession = await this._mux.createSession({
|
|
690
|
+
sessionId: this.id,
|
|
691
|
+
workingDir: this.workingDir,
|
|
692
|
+
mode: this.mode,
|
|
693
|
+
name: this._name,
|
|
694
|
+
niceConfig: this._niceConfig,
|
|
695
|
+
model: this._model,
|
|
696
|
+
claudeMode: this._claudeMode,
|
|
697
|
+
allowedTools: this._allowedTools,
|
|
698
|
+
openCodeConfig: this._openCodeConfig,
|
|
699
|
+
});
|
|
700
|
+
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
|
701
|
+
// No extra sleep — createSession() already waits for tmux readiness
|
|
702
|
+
}
|
|
703
|
+
// Attach to the mux session via PTY
|
|
704
|
+
try {
|
|
705
|
+
this.ptyProcess = pty.spawn(this._mux.getAttachCommand(), this._mux.getAttachArgs(this._muxSession.muxName), {
|
|
706
|
+
name: 'xterm-256color',
|
|
707
|
+
cols: 120,
|
|
708
|
+
rows: 40,
|
|
709
|
+
cwd: this.workingDir,
|
|
710
|
+
env: {
|
|
711
|
+
...process.env,
|
|
712
|
+
LANG: 'en_US.UTF-8',
|
|
713
|
+
LC_ALL: 'en_US.UTF-8',
|
|
714
|
+
TERM: 'xterm-256color',
|
|
715
|
+
COLORTERM: undefined,
|
|
716
|
+
CLAUDECODE: undefined,
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
// Set claudeSessionId immediately since we passed --session-id to Claude
|
|
720
|
+
// The mux manager passes --session-id ${sessionId} to Claude
|
|
721
|
+
this._claudeSessionId = this.id;
|
|
722
|
+
}
|
|
723
|
+
catch (spawnErr) {
|
|
724
|
+
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
|
|
725
|
+
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
|
726
|
+
throw spawnErr;
|
|
727
|
+
}
|
|
728
|
+
// For NEW mux sessions: wait for readiness then clean buffer
|
|
729
|
+
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
|
730
|
+
if (!isRestoredSession) {
|
|
731
|
+
if (this.mode === 'opencode') {
|
|
732
|
+
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
|
733
|
+
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
|
734
|
+
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
|
735
|
+
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
|
736
|
+
this._promptCheckTimeout = setTimeout(() => {
|
|
737
|
+
this._promptCheckTimeout = null;
|
|
738
|
+
if (this._isStopped)
|
|
739
|
+
return;
|
|
740
|
+
this._status = 'idle';
|
|
741
|
+
this.emit('needsRefresh');
|
|
742
|
+
}, 3000);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
// Claude mode: wait for ❯ prompt
|
|
746
|
+
this._promptCheckInterval = setInterval(() => {
|
|
747
|
+
// Wait for the prompt character (❯) which means Claude is fully initialized
|
|
748
|
+
const bufferValue = this._terminalBuffer.value;
|
|
749
|
+
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
|
|
750
|
+
if (this._promptCheckInterval) {
|
|
751
|
+
clearInterval(this._promptCheckInterval);
|
|
752
|
+
this._promptCheckInterval = null;
|
|
753
|
+
}
|
|
754
|
+
if (this._promptCheckTimeout) {
|
|
755
|
+
clearTimeout(this._promptCheckTimeout);
|
|
756
|
+
this._promptCheckTimeout = null;
|
|
757
|
+
}
|
|
758
|
+
// Clean the buffer - remove mux init junk before actual content
|
|
759
|
+
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
|
760
|
+
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
|
761
|
+
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
|
|
762
|
+
// Signal client to refresh
|
|
763
|
+
this.emit('clearTerminal');
|
|
764
|
+
}
|
|
765
|
+
}, 50);
|
|
766
|
+
// Timeout after 5 seconds if prompt not found
|
|
767
|
+
this._promptCheckTimeout = setTimeout(() => {
|
|
768
|
+
if (this._promptCheckInterval) {
|
|
769
|
+
clearInterval(this._promptCheckInterval);
|
|
770
|
+
this._promptCheckInterval = null;
|
|
771
|
+
}
|
|
772
|
+
this._promptCheckTimeout = null;
|
|
773
|
+
}, 5000);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
|
779
|
+
this._useMux = false;
|
|
780
|
+
this._muxSession = null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Fallback to direct PTY if mux is not used
|
|
784
|
+
if (!this.ptyProcess) {
|
|
785
|
+
// OpenCode sessions require tmux for env var injection (API keys via setenv)
|
|
786
|
+
if (this.mode === 'opencode') {
|
|
787
|
+
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
// Pass --session-id to use the SAME ID as the Codeman session
|
|
791
|
+
// This ensures subagents can be directly matched to the correct tab
|
|
792
|
+
const args = [...this._buildPermissionArgs(), '--session-id', this.id];
|
|
793
|
+
if (this._model)
|
|
794
|
+
args.push('--model', this._model);
|
|
795
|
+
this.ptyProcess = pty.spawn('claude', args, {
|
|
796
|
+
name: 'xterm-256color',
|
|
797
|
+
cols: 120,
|
|
798
|
+
rows: 40,
|
|
799
|
+
cwd: this.workingDir,
|
|
800
|
+
env: {
|
|
801
|
+
...process.env,
|
|
802
|
+
LANG: 'en_US.UTF-8',
|
|
803
|
+
LC_ALL: 'en_US.UTF-8',
|
|
804
|
+
PATH: getAugmentedPath(),
|
|
805
|
+
TERM: 'xterm-256color',
|
|
806
|
+
COLORTERM: undefined,
|
|
807
|
+
CLAUDECODE: undefined,
|
|
808
|
+
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
|
809
|
+
CODEMAN_MUX: '1',
|
|
810
|
+
CODEMAN_SESSION_ID: this.id,
|
|
811
|
+
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (spawnErr) {
|
|
816
|
+
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
|
817
|
+
this._status = 'stopped';
|
|
818
|
+
this.emit('error', `Failed to start Claude: ${spawnErr}`);
|
|
819
|
+
throw new Error(`Failed to spawn Claude process: ${spawnErr}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// Set the claudeSessionId immediately since we passed --session-id
|
|
823
|
+
// This ensures subagent matching works without waiting for JSON messages
|
|
824
|
+
this._claudeSessionId = this.id;
|
|
825
|
+
this._pid = this.ptyProcess.pid;
|
|
826
|
+
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
|
827
|
+
this.ptyProcess.onData((rawData) => {
|
|
828
|
+
// Filter out focus escape sequences and Ctrl+L (form feed)
|
|
829
|
+
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
|
830
|
+
if (!data)
|
|
831
|
+
return; // Skip if only filtered sequences
|
|
832
|
+
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
833
|
+
this._terminalBuffer.append(data);
|
|
834
|
+
this._lastActivityAt = Date.now();
|
|
835
|
+
this.emit('terminal', data);
|
|
836
|
+
this.emit('output', data);
|
|
837
|
+
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
|
838
|
+
// Detect if Claude is working or at prompt
|
|
839
|
+
// The prompt line contains "❯" when waiting for input
|
|
840
|
+
if (data.includes('❯') || data.includes('\u276f')) {
|
|
841
|
+
// Only start a new timeout if we're not already awaiting idle confirmation
|
|
842
|
+
// This prevents status bar redraws (which include ❯) from resetting the timer
|
|
843
|
+
if (!this._awaitingIdleConfirmation) {
|
|
844
|
+
if (this.activityTimeout)
|
|
845
|
+
clearTimeout(this.activityTimeout);
|
|
846
|
+
this._awaitingIdleConfirmation = true;
|
|
847
|
+
this.activityTimeout = setTimeout(() => {
|
|
848
|
+
this._awaitingIdleConfirmation = false;
|
|
849
|
+
// Emit idle if either:
|
|
850
|
+
// 1. Claude was working and is now at prompt (normal case)
|
|
851
|
+
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
|
852
|
+
const wasWorking = this._isWorking;
|
|
853
|
+
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
|
854
|
+
if (wasWorking || isInitialReady) {
|
|
855
|
+
this._isWorking = false;
|
|
856
|
+
this._status = 'idle';
|
|
857
|
+
this._lastPromptTime = Date.now();
|
|
858
|
+
this.emit('idle');
|
|
859
|
+
}
|
|
860
|
+
}, IDLE_DETECTION_DELAY_MS);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// Detect when Claude starts working (thinking, writing, etc)
|
|
864
|
+
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
|
|
865
|
+
const hasSpinner = SPINNER_PATTERN.test(data);
|
|
866
|
+
if (hasSpinner) {
|
|
867
|
+
if (!this._isWorking) {
|
|
868
|
+
this._isWorking = true;
|
|
869
|
+
this._status = 'busy';
|
|
870
|
+
this.emit('working');
|
|
871
|
+
}
|
|
872
|
+
this._awaitingIdleConfirmation = false;
|
|
873
|
+
if (this.activityTimeout)
|
|
874
|
+
clearTimeout(this.activityTimeout);
|
|
875
|
+
}
|
|
876
|
+
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
|
|
877
|
+
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
|
|
878
|
+
// raw data and process at most every EXPENSIVE_PROCESS_INTERVAL_MS.
|
|
879
|
+
// This dramatically reduces CPU load with multiple busy sessions.
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
const elapsed = now - this._lastExpensiveProcessTime;
|
|
882
|
+
if (elapsed >= Session.EXPENSIVE_PROCESS_INTERVAL_MS) {
|
|
883
|
+
// Process immediately — include any previously accumulated data
|
|
884
|
+
this._lastExpensiveProcessTime = now;
|
|
885
|
+
const accumulated = this._pendingCleanData ? this._pendingCleanData + data : data;
|
|
886
|
+
this._pendingCleanData = '';
|
|
887
|
+
if (this._expensiveProcessTimer) {
|
|
888
|
+
clearTimeout(this._expensiveProcessTimer);
|
|
889
|
+
this._expensiveProcessTimer = null;
|
|
890
|
+
}
|
|
891
|
+
this._processExpensiveParsers(accumulated);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
// Accumulate for deferred processing
|
|
895
|
+
this._pendingCleanData += data;
|
|
896
|
+
// Cap accumulated size to prevent unbounded growth
|
|
897
|
+
if (this._pendingCleanData.length > 64 * 1024) {
|
|
898
|
+
this._pendingCleanData = this._pendingCleanData.slice(-32 * 1024);
|
|
899
|
+
}
|
|
900
|
+
// Schedule deferred processing if not already scheduled
|
|
901
|
+
if (!this._expensiveProcessTimer) {
|
|
902
|
+
this._expensiveProcessTimer = setTimeout(() => {
|
|
903
|
+
this._expensiveProcessTimer = null;
|
|
904
|
+
this._lastExpensiveProcessTime = Date.now();
|
|
905
|
+
const pending = this._pendingCleanData;
|
|
906
|
+
this._pendingCleanData = '';
|
|
907
|
+
if (pending) {
|
|
908
|
+
this._processExpensiveParsers(pending);
|
|
909
|
+
}
|
|
910
|
+
}, Session.EXPENSIVE_PROCESS_INTERVAL_MS - elapsed);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
915
|
+
console.log('[Session] Interactive PTY exited with code:', exitCode);
|
|
916
|
+
this.ptyProcess = null;
|
|
917
|
+
this._pid = null;
|
|
918
|
+
this._status = 'idle';
|
|
919
|
+
this._awaitingIdleConfirmation = false;
|
|
920
|
+
// Clear all timers to prevent memory leaks
|
|
921
|
+
if (this.activityTimeout) {
|
|
922
|
+
clearTimeout(this.activityTimeout);
|
|
923
|
+
this.activityTimeout = null;
|
|
924
|
+
}
|
|
925
|
+
if (this._promptCheckInterval) {
|
|
926
|
+
clearInterval(this._promptCheckInterval);
|
|
927
|
+
this._promptCheckInterval = null;
|
|
928
|
+
}
|
|
929
|
+
if (this._promptCheckTimeout) {
|
|
930
|
+
clearTimeout(this._promptCheckTimeout);
|
|
931
|
+
this._promptCheckTimeout = null;
|
|
932
|
+
}
|
|
933
|
+
// Clear expensive processing timer and flush any pending data
|
|
934
|
+
if (this._expensiveProcessTimer) {
|
|
935
|
+
clearTimeout(this._expensiveProcessTimer);
|
|
936
|
+
this._expensiveProcessTimer = null;
|
|
937
|
+
}
|
|
938
|
+
this._pendingCleanData = '';
|
|
939
|
+
// If using mux, mark the session as detached but don't kill it
|
|
940
|
+
if (this._muxSession && this._mux) {
|
|
941
|
+
this._mux.setAttached(this.id, false);
|
|
942
|
+
}
|
|
943
|
+
this.emit('exit', exitCode);
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
|
948
|
+
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
|
949
|
+
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
|
950
|
+
*/
|
|
951
|
+
_processExpensiveParsers(rawData) {
|
|
952
|
+
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
|
|
953
|
+
// token parsing, and CLI info parsing all depend on Claude's output format.
|
|
954
|
+
if (this.mode === 'opencode')
|
|
955
|
+
return;
|
|
956
|
+
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
|
957
|
+
let _cleanData = null;
|
|
958
|
+
const getCleanData = () => {
|
|
959
|
+
if (_cleanData === null) {
|
|
960
|
+
_cleanData = rawData.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
|
961
|
+
}
|
|
962
|
+
return _cleanData;
|
|
963
|
+
};
|
|
964
|
+
// Forward to Ralph tracker to detect Ralph loops and todos
|
|
965
|
+
// (opencode sessions already returned early at line 1209)
|
|
966
|
+
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
|
|
967
|
+
this._ralphTracker.processCleanData(getCleanData());
|
|
968
|
+
}
|
|
969
|
+
// Forward to Bash tool parser to detect file-viewing commands
|
|
970
|
+
if (this._bashToolParser.enabled) {
|
|
971
|
+
this._bashToolParser.processCleanData(getCleanData());
|
|
972
|
+
}
|
|
973
|
+
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
|
974
|
+
if (rawData.includes('token')) {
|
|
975
|
+
this.parseTokensFromStatusLine(getCleanData());
|
|
976
|
+
}
|
|
977
|
+
// Parse Claude Code CLI info (version, model, account type) from startup
|
|
978
|
+
if (!this._cliInfoParsed) {
|
|
979
|
+
this.parseClaudeCodeInfo(getCleanData());
|
|
980
|
+
}
|
|
981
|
+
// Parse task descriptions from terminal output (e.g., "Explore(Check files)")
|
|
982
|
+
if (rawData.includes('(') && rawData.includes(')')) {
|
|
983
|
+
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
|
984
|
+
}
|
|
985
|
+
// Work keyword detection (text-based, needs clean data)
|
|
986
|
+
// Only check if spinner didn't already trigger working state
|
|
987
|
+
if (!this._isWorking) {
|
|
988
|
+
const cleanData = getCleanData();
|
|
989
|
+
if (cleanData.includes('Thinking') ||
|
|
990
|
+
cleanData.includes('Writing') ||
|
|
991
|
+
cleanData.includes('Reading') ||
|
|
992
|
+
cleanData.includes('Running')) {
|
|
993
|
+
this._isWorking = true;
|
|
994
|
+
this._status = 'busy';
|
|
995
|
+
this.emit('working');
|
|
996
|
+
this._awaitingIdleConfirmation = false;
|
|
997
|
+
if (this.activityTimeout)
|
|
998
|
+
clearTimeout(this.activityTimeout);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Starts a plain shell session (bash/zsh) without Claude CLI.
|
|
1004
|
+
*
|
|
1005
|
+
* Useful for debugging, testing, or when you just need a terminal.
|
|
1006
|
+
* Uses the user's default shell from $SHELL or falls back to /bin/bash.
|
|
1007
|
+
*
|
|
1008
|
+
* @throws {Error} If a process is already running in this session
|
|
1009
|
+
*
|
|
1010
|
+
* @example
|
|
1011
|
+
* ```typescript
|
|
1012
|
+
* const session = new Session({ workingDir: '/project', mode: 'shell' });
|
|
1013
|
+
* await session.startShell();
|
|
1014
|
+
* session.write('ls -la\r');
|
|
1015
|
+
* ```
|
|
1016
|
+
*/
|
|
1017
|
+
async startShell() {
|
|
1018
|
+
if (this.ptyProcess) {
|
|
1019
|
+
throw new Error('Session already has a running process');
|
|
1020
|
+
}
|
|
1021
|
+
this._status = 'busy';
|
|
1022
|
+
this._terminalBuffer.clear();
|
|
1023
|
+
this._textOutput.clear();
|
|
1024
|
+
this._errorBuffer = '';
|
|
1025
|
+
this._messages = [];
|
|
1026
|
+
this._lineBuffer = '';
|
|
1027
|
+
this._lastActivityAt = Date.now();
|
|
1028
|
+
// Use user's default shell or bash
|
|
1029
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
1030
|
+
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux.backend})` : ''));
|
|
1031
|
+
// If mux wrapping is enabled, create or attach to a mux session
|
|
1032
|
+
if (this._useMux && this._mux) {
|
|
1033
|
+
try {
|
|
1034
|
+
// Verify stale mux session — tmux may have been destroyed externally
|
|
1035
|
+
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
|
1036
|
+
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
|
1037
|
+
this._muxSession = null;
|
|
1038
|
+
}
|
|
1039
|
+
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
|
1040
|
+
let needsNewSession = false;
|
|
1041
|
+
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
|
1042
|
+
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
|
1043
|
+
const newPid = await this._mux.respawnPane({
|
|
1044
|
+
sessionId: this.id,
|
|
1045
|
+
workingDir: this.workingDir,
|
|
1046
|
+
mode: 'shell',
|
|
1047
|
+
niceConfig: this._niceConfig,
|
|
1048
|
+
});
|
|
1049
|
+
if (!newPid) {
|
|
1050
|
+
console.error('[Session] Failed to respawn pane, will create new session');
|
|
1051
|
+
needsNewSession = true;
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Check if we already have a mux session (restored session)
|
|
1058
|
+
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
|
1059
|
+
if (isRestoredSession) {
|
|
1060
|
+
console.log('[Session] Attaching to existing mux session:', this._muxSession.muxName);
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
// Create a new mux session
|
|
1064
|
+
this._muxSession = await this._mux.createSession({
|
|
1065
|
+
sessionId: this.id,
|
|
1066
|
+
workingDir: this.workingDir,
|
|
1067
|
+
mode: 'shell',
|
|
1068
|
+
name: this._name,
|
|
1069
|
+
niceConfig: this._niceConfig,
|
|
1070
|
+
});
|
|
1071
|
+
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
|
1072
|
+
// No extra sleep — createSession() already waits for tmux readiness
|
|
1073
|
+
}
|
|
1074
|
+
// Attach to the mux session via PTY
|
|
1075
|
+
try {
|
|
1076
|
+
this.ptyProcess = pty.spawn(this._mux.getAttachCommand(), this._mux.getAttachArgs(this._muxSession.muxName), {
|
|
1077
|
+
name: 'xterm-256color',
|
|
1078
|
+
cols: 120,
|
|
1079
|
+
rows: 40,
|
|
1080
|
+
cwd: this.workingDir,
|
|
1081
|
+
env: {
|
|
1082
|
+
...process.env,
|
|
1083
|
+
LANG: 'en_US.UTF-8',
|
|
1084
|
+
LC_ALL: 'en_US.UTF-8',
|
|
1085
|
+
TERM: 'xterm-256color',
|
|
1086
|
+
COLORTERM: undefined,
|
|
1087
|
+
CLAUDECODE: undefined,
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
catch (spawnErr) {
|
|
1092
|
+
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
|
1093
|
+
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
|
1094
|
+
throw spawnErr;
|
|
1095
|
+
}
|
|
1096
|
+
// For NEW sessions: clear by sending 'clear' command to the shell
|
|
1097
|
+
// For RESTORED sessions: don't clear - we want to see the existing output
|
|
1098
|
+
if (!isRestoredSession) {
|
|
1099
|
+
setTimeout(() => {
|
|
1100
|
+
if (this.ptyProcess) {
|
|
1101
|
+
this._terminalBuffer.clear();
|
|
1102
|
+
this.ptyProcess.write('clear\n');
|
|
1103
|
+
}
|
|
1104
|
+
}, 100);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (err) {
|
|
1108
|
+
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
|
1109
|
+
this._useMux = false;
|
|
1110
|
+
this._muxSession = null;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Fallback to direct PTY if mux is not used
|
|
1114
|
+
if (!this.ptyProcess) {
|
|
1115
|
+
try {
|
|
1116
|
+
this.ptyProcess = pty.spawn(shell, [], {
|
|
1117
|
+
name: 'xterm-256color',
|
|
1118
|
+
cols: 120,
|
|
1119
|
+
rows: 40,
|
|
1120
|
+
cwd: this.workingDir,
|
|
1121
|
+
env: {
|
|
1122
|
+
...process.env,
|
|
1123
|
+
LANG: 'en_US.UTF-8',
|
|
1124
|
+
LC_ALL: 'en_US.UTF-8',
|
|
1125
|
+
TERM: 'xterm-256color',
|
|
1126
|
+
CODEMAN_MUX: '1',
|
|
1127
|
+
CODEMAN_SESSION_ID: this.id,
|
|
1128
|
+
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
catch (spawnErr) {
|
|
1133
|
+
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
|
1134
|
+
this._status = 'stopped';
|
|
1135
|
+
this.emit('error', `Failed to start shell: ${spawnErr}`);
|
|
1136
|
+
throw new Error(`Failed to spawn shell process: ${spawnErr}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
this._pid = this.ptyProcess.pid;
|
|
1140
|
+
console.log('[Session] Shell PTY spawned with PID:', this._pid);
|
|
1141
|
+
this.ptyProcess.onData((rawData) => {
|
|
1142
|
+
// Filter out focus escape sequences
|
|
1143
|
+
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
|
1144
|
+
if (!data)
|
|
1145
|
+
return; // Skip if only focus sequences
|
|
1146
|
+
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
1147
|
+
this._terminalBuffer.append(data);
|
|
1148
|
+
this._lastActivityAt = Date.now();
|
|
1149
|
+
this.emit('terminal', data);
|
|
1150
|
+
this.emit('output', data);
|
|
1151
|
+
});
|
|
1152
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
1153
|
+
console.log('[Session] Shell PTY exited with code:', exitCode);
|
|
1154
|
+
this.ptyProcess = null;
|
|
1155
|
+
this._pid = null;
|
|
1156
|
+
this._status = 'idle';
|
|
1157
|
+
// Clear timers to prevent memory leaks
|
|
1158
|
+
if (this._shellIdleTimer) {
|
|
1159
|
+
clearTimeout(this._shellIdleTimer);
|
|
1160
|
+
this._shellIdleTimer = null;
|
|
1161
|
+
}
|
|
1162
|
+
if (this.activityTimeout) {
|
|
1163
|
+
clearTimeout(this.activityTimeout);
|
|
1164
|
+
this.activityTimeout = null;
|
|
1165
|
+
}
|
|
1166
|
+
// If using mux, mark the session as detached but don't kill it
|
|
1167
|
+
if (this._muxSession && this._mux) {
|
|
1168
|
+
this._mux.setAttached(this.id, false);
|
|
1169
|
+
}
|
|
1170
|
+
this.emit('exit', exitCode);
|
|
1171
|
+
});
|
|
1172
|
+
// Mark as idle after a short delay (shell is ready)
|
|
1173
|
+
this._shellIdleTimer = setTimeout(() => {
|
|
1174
|
+
this._shellIdleTimer = null;
|
|
1175
|
+
this._status = 'idle';
|
|
1176
|
+
this._isWorking = false;
|
|
1177
|
+
this.emit('idle');
|
|
1178
|
+
}, 500);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Runs a one-shot prompt and returns the result.
|
|
1182
|
+
*
|
|
1183
|
+
* This spawns Claude CLI with `--output-format stream-json` to get
|
|
1184
|
+
* structured JSON output. The promise resolves when Claude completes
|
|
1185
|
+
* the response.
|
|
1186
|
+
*
|
|
1187
|
+
* @param prompt - The prompt text to send to Claude
|
|
1188
|
+
* @param options - Optional configuration
|
|
1189
|
+
* @param options.model - Model to use ('opus', 'sonnet', or full model name). Defaults to default model.
|
|
1190
|
+
* @param options.onProgress - Callback for progress updates (token count, status)
|
|
1191
|
+
* @returns Promise resolving to the result text and total cost in USD
|
|
1192
|
+
* @throws {Error} If a process is already running in this session
|
|
1193
|
+
*
|
|
1194
|
+
* @example
|
|
1195
|
+
* ```typescript
|
|
1196
|
+
* const session = new Session({ workingDir: '/project' });
|
|
1197
|
+
* const { result, cost } = await session.runPrompt('Explain this code', { model: 'opus' });
|
|
1198
|
+
* console.log(`Response: ${result}`);
|
|
1199
|
+
* console.log(`Cost: $${cost.toFixed(4)}`);
|
|
1200
|
+
* ```
|
|
1201
|
+
*/
|
|
1202
|
+
async runPrompt(prompt, options) {
|
|
1203
|
+
return new Promise((resolve, reject) => {
|
|
1204
|
+
if (this.ptyProcess) {
|
|
1205
|
+
reject(new Error('Session already has a running process'));
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
this._status = 'busy';
|
|
1209
|
+
this._terminalBuffer.clear();
|
|
1210
|
+
this._textOutput.clear();
|
|
1211
|
+
this._errorBuffer = '';
|
|
1212
|
+
this._messages = [];
|
|
1213
|
+
this._lineBuffer = '';
|
|
1214
|
+
this._lastActivityAt = Date.now();
|
|
1215
|
+
this._promptResolved = false; // Reset race condition guard
|
|
1216
|
+
this.resolvePromise = resolve;
|
|
1217
|
+
this.rejectPromise = reject;
|
|
1218
|
+
try {
|
|
1219
|
+
// Spawn claude in a real PTY
|
|
1220
|
+
const model = options?.model;
|
|
1221
|
+
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : '');
|
|
1222
|
+
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
|
1223
|
+
if (model) {
|
|
1224
|
+
args.push('--model', model);
|
|
1225
|
+
}
|
|
1226
|
+
args.push(prompt);
|
|
1227
|
+
try {
|
|
1228
|
+
this.ptyProcess = pty.spawn('claude', args, {
|
|
1229
|
+
name: 'xterm-256color',
|
|
1230
|
+
cols: 120,
|
|
1231
|
+
rows: 40,
|
|
1232
|
+
cwd: this.workingDir,
|
|
1233
|
+
env: {
|
|
1234
|
+
...process.env,
|
|
1235
|
+
LANG: 'en_US.UTF-8',
|
|
1236
|
+
LC_ALL: 'en_US.UTF-8',
|
|
1237
|
+
PATH: getAugmentedPath(),
|
|
1238
|
+
TERM: 'xterm-256color',
|
|
1239
|
+
COLORTERM: undefined,
|
|
1240
|
+
CLAUDECODE: undefined,
|
|
1241
|
+
// Inform Claude it's running within Codeman
|
|
1242
|
+
CODEMAN_MUX: '1',
|
|
1243
|
+
CODEMAN_SESSION_ID: this.id,
|
|
1244
|
+
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
catch (spawnErr) {
|
|
1249
|
+
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
|
1250
|
+
this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`);
|
|
1251
|
+
throw spawnErr;
|
|
1252
|
+
}
|
|
1253
|
+
this._pid = this.ptyProcess.pid;
|
|
1254
|
+
console.log('[Session] PTY spawned with PID:', this._pid);
|
|
1255
|
+
// Handle terminal data
|
|
1256
|
+
this.ptyProcess.onData((rawData) => {
|
|
1257
|
+
// Filter out focus escape sequences
|
|
1258
|
+
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
|
1259
|
+
if (!data)
|
|
1260
|
+
return; // Skip if only focus sequences
|
|
1261
|
+
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
1262
|
+
this._terminalBuffer.append(data);
|
|
1263
|
+
this._lastActivityAt = Date.now();
|
|
1264
|
+
this.emit('terminal', data);
|
|
1265
|
+
this.emit('output', data);
|
|
1266
|
+
// Also try to parse JSON lines for structured data
|
|
1267
|
+
this.processOutput(data);
|
|
1268
|
+
});
|
|
1269
|
+
// Handle exit
|
|
1270
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
1271
|
+
console.log('[Session] PTY exited with code:', exitCode);
|
|
1272
|
+
this.ptyProcess = null;
|
|
1273
|
+
this._pid = null;
|
|
1274
|
+
// Guard against race conditions: only process once per runPrompt call
|
|
1275
|
+
if (this._promptResolved) {
|
|
1276
|
+
this.emit('exit', exitCode);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
this._promptResolved = true;
|
|
1280
|
+
// Capture callbacks atomically before processing
|
|
1281
|
+
const resolve = this.resolvePromise;
|
|
1282
|
+
const reject = this.rejectPromise;
|
|
1283
|
+
this.resolvePromise = null;
|
|
1284
|
+
this.rejectPromise = null;
|
|
1285
|
+
// Find result from parsed messages or use text output
|
|
1286
|
+
const resultMsg = this._messages.find((m) => m.type === 'result');
|
|
1287
|
+
if (resultMsg && !resultMsg.is_error) {
|
|
1288
|
+
this._status = 'idle';
|
|
1289
|
+
const cost = resultMsg.total_cost_usd || 0;
|
|
1290
|
+
this._totalCost += cost;
|
|
1291
|
+
this.emit('completion', resultMsg.result || '', cost);
|
|
1292
|
+
if (resolve) {
|
|
1293
|
+
resolve({ result: resultMsg.result || '', cost });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
|
|
1297
|
+
this._status = 'error';
|
|
1298
|
+
if (reject) {
|
|
1299
|
+
reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error'));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
this._status = 'idle';
|
|
1304
|
+
if (resolve) {
|
|
1305
|
+
resolve({
|
|
1306
|
+
result: this._textOutput.value || this._terminalBuffer.value,
|
|
1307
|
+
cost: this._totalCost,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
this.emit('exit', exitCode);
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
this._status = 'error';
|
|
1316
|
+
reject(err);
|
|
1317
|
+
// Null callbacks to prevent memory leak (onExit won't run if spawn failed)
|
|
1318
|
+
this.resolvePromise = null;
|
|
1319
|
+
this.rejectPromise = null;
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
processOutput(data) {
|
|
1324
|
+
// Early return if session is stopped to prevent any processing or timer creation
|
|
1325
|
+
if (this._isStopped)
|
|
1326
|
+
return;
|
|
1327
|
+
// Try to extract JSON from output (Claude may output JSON in stream mode)
|
|
1328
|
+
this._lineBuffer += data;
|
|
1329
|
+
// Prevent unbounded line buffer growth for very long lines
|
|
1330
|
+
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
|
1331
|
+
// Force flush the oversized buffer as text output
|
|
1332
|
+
this._textOutput.append(this._lineBuffer + '\n');
|
|
1333
|
+
this._lineBuffer = '';
|
|
1334
|
+
}
|
|
1335
|
+
// Start flush timer if not running (handles partial lines after 100ms)
|
|
1336
|
+
if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0 && !this._isStopped) {
|
|
1337
|
+
this._lineBufferFlushTimer = setTimeout(() => {
|
|
1338
|
+
this._lineBufferFlushTimer = null;
|
|
1339
|
+
if (this._lineBuffer.length > 0 && !this._isStopped) {
|
|
1340
|
+
// Flush partial line as text output
|
|
1341
|
+
this._textOutput.append(this._lineBuffer);
|
|
1342
|
+
this._lineBuffer = '';
|
|
1343
|
+
}
|
|
1344
|
+
}, LINE_BUFFER_FLUSH_INTERVAL);
|
|
1345
|
+
}
|
|
1346
|
+
const lines = this._lineBuffer.split('\n');
|
|
1347
|
+
this._lineBuffer = lines.pop() || '';
|
|
1348
|
+
// Clear flush timer if buffer is now empty
|
|
1349
|
+
if (this._lineBuffer.length === 0 && this._lineBufferFlushTimer) {
|
|
1350
|
+
clearTimeout(this._lineBufferFlushTimer);
|
|
1351
|
+
this._lineBufferFlushTimer = null;
|
|
1352
|
+
}
|
|
1353
|
+
for (const line of lines) {
|
|
1354
|
+
const trimmed = line.trim();
|
|
1355
|
+
// Remove ANSI escape codes for JSON parsing (use pre-compiled pattern)
|
|
1356
|
+
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
|
1357
|
+
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
|
|
1358
|
+
try {
|
|
1359
|
+
const msg = JSON.parse(cleanLine);
|
|
1360
|
+
this._messages.push(msg);
|
|
1361
|
+
this.emit('message', msg);
|
|
1362
|
+
// Trim messages array for long-running sessions
|
|
1363
|
+
if (this._messages.length > MAX_MESSAGES) {
|
|
1364
|
+
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
|
|
1365
|
+
}
|
|
1366
|
+
// Extract Claude session ID from messages (can be in any message type)
|
|
1367
|
+
// Support both sessionId (camelCase) and session_id (snake_case)
|
|
1368
|
+
const msgSessionId = msg.sessionId ?? msg.session_id;
|
|
1369
|
+
if (msgSessionId && !this._claudeSessionId) {
|
|
1370
|
+
this._claudeSessionId = msgSessionId;
|
|
1371
|
+
}
|
|
1372
|
+
// Process message for task tracking
|
|
1373
|
+
this._taskTracker.processMessage(msg);
|
|
1374
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
1375
|
+
for (const block of msg.message.content) {
|
|
1376
|
+
if (block.type === 'text' && block.text) {
|
|
1377
|
+
this._textOutput.append(block.text);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
// Track tokens from usage (with validation)
|
|
1381
|
+
if (msg.message.usage) {
|
|
1382
|
+
const inputDelta = msg.message.usage.input_tokens || 0;
|
|
1383
|
+
const outputDelta = msg.message.usage.output_tokens || 0;
|
|
1384
|
+
// Sanity check: max 100k tokens per message (generous limit)
|
|
1385
|
+
const MAX_TOKENS_PER_MESSAGE = 100_000;
|
|
1386
|
+
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
|
1387
|
+
this._totalInputTokens += inputDelta;
|
|
1388
|
+
}
|
|
1389
|
+
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
|
1390
|
+
this._totalOutputTokens += outputDelta;
|
|
1391
|
+
}
|
|
1392
|
+
// Check if we should auto-compact or auto-clear
|
|
1393
|
+
this.checkAutoCompact();
|
|
1394
|
+
this.checkAutoClear();
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (msg.type === 'result' && msg.total_cost_usd) {
|
|
1398
|
+
this._totalCost = msg.total_cost_usd;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (parseErr) {
|
|
1402
|
+
// Not JSON, just regular output - this is expected for non-JSON lines
|
|
1403
|
+
console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr);
|
|
1404
|
+
this._textOutput.append(line + '\n');
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
else if (trimmed) {
|
|
1408
|
+
this._textOutput.append(line + '\n');
|
|
1409
|
+
}
|
|
1410
|
+
// Parse task descriptions from terminal output (e.g., "Explore(Description)")
|
|
1411
|
+
// This captures the short description from Claude Code's Task tool output
|
|
1412
|
+
// Use direct method since cleanLine is already ANSI-stripped (line 1460)
|
|
1413
|
+
this.parseTaskDescriptionsDirect(cleanLine);
|
|
1414
|
+
}
|
|
1415
|
+
// Note: BufferAccumulator auto-trims when max size exceeded
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Parse task descriptions from terminal data (may contain multiple lines).
|
|
1419
|
+
* Called from interactive mode's onData handler with ANSI-stripped data.
|
|
1420
|
+
* @param cleanData - Terminal data with ANSI codes already stripped
|
|
1421
|
+
*/
|
|
1422
|
+
parseTaskDescriptionsFromTerminalData(cleanData) {
|
|
1423
|
+
// Quick pre-check: skip if no parentheses present
|
|
1424
|
+
if (!cleanData.includes('(') || !cleanData.includes(')'))
|
|
1425
|
+
return;
|
|
1426
|
+
// Split by newlines and process each line (data already ANSI-stripped)
|
|
1427
|
+
const lines = cleanData.split(NEWLINE_SPLIT_PATTERN);
|
|
1428
|
+
for (const line of lines) {
|
|
1429
|
+
this.parseTaskDescriptionsDirect(line);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Parse task descriptions from a pre-cleaned line (no ANSI codes).
|
|
1434
|
+
* Used by both processOutput() and parseTaskDescriptionsFromTerminalData().
|
|
1435
|
+
*/
|
|
1436
|
+
parseTaskDescriptionsDirect(cleanLine) {
|
|
1437
|
+
// Quick pre-check: skip expensive regex if no common tool patterns present
|
|
1438
|
+
if (!cleanLine.includes('(') || !cleanLine.includes(')'))
|
|
1439
|
+
return;
|
|
1440
|
+
// Reset regex lastIndex for global pattern
|
|
1441
|
+
TASK_TOOL_PATTERN.lastIndex = 0;
|
|
1442
|
+
let match;
|
|
1443
|
+
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
|
|
1444
|
+
const description = match[2].trim();
|
|
1445
|
+
if (description && description.length > 0) {
|
|
1446
|
+
const now = Date.now();
|
|
1447
|
+
this._recentTaskDescriptions.set(now, description);
|
|
1448
|
+
// Cleanup old entries
|
|
1449
|
+
this.cleanupOldTaskDescriptions();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Remove task descriptions older than TASK_DESCRIPTION_MAX_AGE_MS.
|
|
1455
|
+
* Size limit is handled automatically by LRUMap eviction on set().
|
|
1456
|
+
*/
|
|
1457
|
+
cleanupOldTaskDescriptions() {
|
|
1458
|
+
const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS;
|
|
1459
|
+
// Keys are timestamps - iterate and delete expired entries
|
|
1460
|
+
// LRUMap maintains insertion order, so we can break early once we find a non-expired entry
|
|
1461
|
+
for (const timestamp of this._recentTaskDescriptions.keysInOrder()) {
|
|
1462
|
+
if (timestamp < cutoff) {
|
|
1463
|
+
this._recentTaskDescriptions.delete(timestamp);
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
// Keys are ordered by insertion time (which is the timestamp)
|
|
1467
|
+
// Once we find a non-expired one, all subsequent are also non-expired
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Get recent task descriptions parsed from terminal output.
|
|
1474
|
+
* Returns descriptions sorted by timestamp (most recent first).
|
|
1475
|
+
*/
|
|
1476
|
+
getRecentTaskDescriptions() {
|
|
1477
|
+
this.cleanupOldTaskDescriptions();
|
|
1478
|
+
const results = [];
|
|
1479
|
+
for (const [timestamp, description] of this._recentTaskDescriptions) {
|
|
1480
|
+
results.push({ timestamp, description });
|
|
1481
|
+
}
|
|
1482
|
+
return results.sort((a, b) => b.timestamp - a.timestamp);
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Find a task description that was parsed close to a given timestamp.
|
|
1486
|
+
* Used to correlate with SubagentWatcher discoveries.
|
|
1487
|
+
*
|
|
1488
|
+
* @param subagentStartTime - The timestamp when the subagent was discovered
|
|
1489
|
+
* @param maxAgeMs - Maximum age difference to consider (default 10 seconds)
|
|
1490
|
+
* @returns The matching description or undefined
|
|
1491
|
+
*/
|
|
1492
|
+
findTaskDescriptionNear(subagentStartTime, maxAgeMs = 10000) {
|
|
1493
|
+
this.cleanupOldTaskDescriptions();
|
|
1494
|
+
// Find the most recent description that was parsed before or around the subagent start time
|
|
1495
|
+
let bestMatch;
|
|
1496
|
+
let bestDiff = Infinity;
|
|
1497
|
+
for (const [timestamp, description] of this._recentTaskDescriptions) {
|
|
1498
|
+
const diff = Math.abs(subagentStartTime - timestamp);
|
|
1499
|
+
if (diff < maxAgeMs && diff < bestDiff) {
|
|
1500
|
+
bestMatch = { timestamp, description };
|
|
1501
|
+
bestDiff = diff;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return bestMatch?.description;
|
|
1505
|
+
}
|
|
1506
|
+
// Parse token count from Claude's status line in interactive mode
|
|
1507
|
+
// Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens"
|
|
1508
|
+
//
|
|
1509
|
+
// SAFETY LIMITS:
|
|
1510
|
+
// - Max tokens per session: 500k (Claude's context is ~200k)
|
|
1511
|
+
// - Max delta per update: 100k (prevents sudden jumps from parsing errors)
|
|
1512
|
+
// - Rejects "M" suffix values > 0.5 (500k) to prevent false matches
|
|
1513
|
+
parseTokensFromStatusLine(cleanData) {
|
|
1514
|
+
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
|
|
1515
|
+
if (!cleanData.includes('token'))
|
|
1516
|
+
return;
|
|
1517
|
+
// Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens"
|
|
1518
|
+
// The status line typically shows total tokens like "1.2k tokens" near the prompt
|
|
1519
|
+
// Note: ANSI codes are already stripped by caller for performance
|
|
1520
|
+
const tokenMatch = cleanData.match(TOKEN_PATTERN);
|
|
1521
|
+
if (tokenMatch) {
|
|
1522
|
+
let tokenCount = parseFloat(tokenMatch[1]);
|
|
1523
|
+
const suffix = tokenMatch[2]?.toLowerCase();
|
|
1524
|
+
// Convert k/M suffix to actual number
|
|
1525
|
+
if (suffix === 'k') {
|
|
1526
|
+
tokenCount *= 1000;
|
|
1527
|
+
}
|
|
1528
|
+
else if (suffix === 'm') {
|
|
1529
|
+
// Safety: Reject M values that would result in > 500k tokens
|
|
1530
|
+
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
|
1531
|
+
if (tokenCount > 0.5) {
|
|
1532
|
+
console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
tokenCount *= 1000000;
|
|
1536
|
+
}
|
|
1537
|
+
// Safety: Absolute maximum tokens per session
|
|
1538
|
+
if (tokenCount > MAX_SESSION_TOKENS) {
|
|
1539
|
+
console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
// Only update if the new count is higher (tokens only increase within a session)
|
|
1543
|
+
// We use total tokens as an estimate - Claude shows combined input+output
|
|
1544
|
+
const currentTotal = this._totalInputTokens + this._totalOutputTokens;
|
|
1545
|
+
if (tokenCount > currentTotal) {
|
|
1546
|
+
const delta = tokenCount - currentTotal;
|
|
1547
|
+
// Safety: Reject suspiciously large jumps (max 100k per update)
|
|
1548
|
+
const MAX_DELTA_PER_UPDATE = 100_000;
|
|
1549
|
+
if (delta > MAX_DELTA_PER_UPDATE) {
|
|
1550
|
+
console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
// Estimate: split roughly 60% input, 40% output (common ratio)
|
|
1554
|
+
// This is an approximation since interactive mode doesn't give us the breakdown
|
|
1555
|
+
this._totalInputTokens += Math.round(delta * 0.6);
|
|
1556
|
+
this._totalOutputTokens += Math.round(delta * 0.4);
|
|
1557
|
+
// Check if we should auto-compact or auto-clear
|
|
1558
|
+
this.checkAutoCompact();
|
|
1559
|
+
this.checkAutoClear();
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
// Parse Claude Code CLI info from terminal startup output
|
|
1564
|
+
// Extracts version, model, and account type for display in Codeman UI
|
|
1565
|
+
// Note: Expects cleanData with ANSI codes already stripped by caller
|
|
1566
|
+
parseClaudeCodeInfo(cleanData) {
|
|
1567
|
+
// Only parse once per session (during startup)
|
|
1568
|
+
if (this._cliInfoParsed)
|
|
1569
|
+
return;
|
|
1570
|
+
// Quick pre-checks
|
|
1571
|
+
if (!cleanData.includes('Claude') &&
|
|
1572
|
+
!cleanData.includes('current:') &&
|
|
1573
|
+
!cleanData.includes('Opus') &&
|
|
1574
|
+
!cleanData.includes('Sonnet')) {
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
let changed = false;
|
|
1578
|
+
// Match "Claude Code v2.1.27" or "Claude Code vX.Y.Z"
|
|
1579
|
+
if (!this._cliVersion) {
|
|
1580
|
+
const versionMatch = cleanData.match(/Claude Code v(\d+\.\d+\.\d+)/);
|
|
1581
|
+
if (versionMatch) {
|
|
1582
|
+
this._cliVersion = versionMatch[1];
|
|
1583
|
+
changed = true;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// Match model and account: "Opus 4.5 · Claude Max" or "Sonnet 4 · API"
|
|
1587
|
+
// The · character separates model from account type
|
|
1588
|
+
if (!this._cliModel || !this._cliAccountType) {
|
|
1589
|
+
// Try various model patterns
|
|
1590
|
+
const modelPatterns = [
|
|
1591
|
+
/(Opus \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
1592
|
+
/(Sonnet \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
1593
|
+
/(Haiku \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
1594
|
+
];
|
|
1595
|
+
for (const pattern of modelPatterns) {
|
|
1596
|
+
const match = cleanData.match(pattern);
|
|
1597
|
+
if (match) {
|
|
1598
|
+
if (!this._cliModel) {
|
|
1599
|
+
this._cliModel = match[1].trim();
|
|
1600
|
+
changed = true;
|
|
1601
|
+
}
|
|
1602
|
+
if (!this._cliAccountType) {
|
|
1603
|
+
this._cliAccountType = match[2].trim();
|
|
1604
|
+
changed = true;
|
|
1605
|
+
}
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
// Match version check: "current: 2.1.27" and "latest: 2.1.27"
|
|
1611
|
+
if (!this._cliLatestVersion) {
|
|
1612
|
+
const latestMatch = cleanData.match(/latest:\s*(\d+\.\d+\.\d+)/);
|
|
1613
|
+
if (latestMatch) {
|
|
1614
|
+
this._cliLatestVersion = latestMatch[1];
|
|
1615
|
+
changed = true;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
// Mark as parsed once we have the essential info
|
|
1619
|
+
if (this._cliVersion && this._cliModel) {
|
|
1620
|
+
this._cliInfoParsed = true;
|
|
1621
|
+
}
|
|
1622
|
+
// Emit update if anything changed
|
|
1623
|
+
if (changed) {
|
|
1624
|
+
this.emit('cliInfoUpdated', {
|
|
1625
|
+
version: this._cliVersion,
|
|
1626
|
+
model: this._cliModel,
|
|
1627
|
+
accountType: this._cliAccountType,
|
|
1628
|
+
latestVersion: this._cliLatestVersion,
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// Check if we should auto-compact based on token threshold
|
|
1633
|
+
checkAutoCompact() {
|
|
1634
|
+
if (this._isStopped)
|
|
1635
|
+
return; // Early exit check
|
|
1636
|
+
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing)
|
|
1637
|
+
return;
|
|
1638
|
+
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
|
|
1639
|
+
if (totalTokens >= this._autoCompactThreshold) {
|
|
1640
|
+
this._isCompacting = true;
|
|
1641
|
+
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
|
|
1642
|
+
// Wait for Claude to be idle before compacting
|
|
1643
|
+
const checkAndCompact = async () => {
|
|
1644
|
+
// Check if session is still valid (not stopped) - must be first check
|
|
1645
|
+
if (this._isStopped)
|
|
1646
|
+
return;
|
|
1647
|
+
if (!this._isCompacting)
|
|
1648
|
+
return;
|
|
1649
|
+
if (!this._isWorking) {
|
|
1650
|
+
// Re-check stopped state after async operation might have completed
|
|
1651
|
+
if (this._isStopped)
|
|
1652
|
+
return;
|
|
1653
|
+
// Send /compact command with optional prompt
|
|
1654
|
+
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
|
1655
|
+
await this.writeViaMux(compactCmd);
|
|
1656
|
+
this.emit('autoCompact', {
|
|
1657
|
+
tokens: totalTokens,
|
|
1658
|
+
threshold: this._autoCompactThreshold,
|
|
1659
|
+
prompt: this._autoCompactPrompt || undefined,
|
|
1660
|
+
});
|
|
1661
|
+
// Wait a moment then re-enable (longer than clear since compact takes time)
|
|
1662
|
+
if (!this._isStopped) {
|
|
1663
|
+
this._autoCompactTimer = setTimeout(() => {
|
|
1664
|
+
if (this._isStopped)
|
|
1665
|
+
return; // Check at callback start
|
|
1666
|
+
this._autoCompactTimer = null;
|
|
1667
|
+
this._isCompacting = false;
|
|
1668
|
+
}, 10000);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
else {
|
|
1672
|
+
// Check again after delay
|
|
1673
|
+
if (!this._isStopped) {
|
|
1674
|
+
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
// Start checking after a short delay
|
|
1679
|
+
if (!this._isStopped) {
|
|
1680
|
+
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
// Check if we should auto-clear based on token threshold
|
|
1685
|
+
checkAutoClear() {
|
|
1686
|
+
if (this._isStopped)
|
|
1687
|
+
return; // Early exit check
|
|
1688
|
+
if (!this._autoClearEnabled || this._isClearing || this._isCompacting)
|
|
1689
|
+
return;
|
|
1690
|
+
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
|
|
1691
|
+
if (totalTokens >= this._autoClearThreshold) {
|
|
1692
|
+
this._isClearing = true;
|
|
1693
|
+
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
|
|
1694
|
+
// Wait for Claude to be idle before clearing
|
|
1695
|
+
const checkAndClear = async () => {
|
|
1696
|
+
// Check if session is still valid (not stopped) - must be first check
|
|
1697
|
+
if (this._isStopped)
|
|
1698
|
+
return;
|
|
1699
|
+
if (!this._isClearing)
|
|
1700
|
+
return;
|
|
1701
|
+
if (!this._isWorking) {
|
|
1702
|
+
// Re-check stopped state after async operation might have completed
|
|
1703
|
+
if (this._isStopped)
|
|
1704
|
+
return;
|
|
1705
|
+
// Send /clear command
|
|
1706
|
+
await this.writeViaMux('/clear\r');
|
|
1707
|
+
// Reset token counts
|
|
1708
|
+
this._totalInputTokens = 0;
|
|
1709
|
+
this._totalOutputTokens = 0;
|
|
1710
|
+
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
|
1711
|
+
// Wait a moment then re-enable
|
|
1712
|
+
if (!this._isStopped) {
|
|
1713
|
+
this._autoClearTimer = setTimeout(() => {
|
|
1714
|
+
if (this._isStopped)
|
|
1715
|
+
return; // Check at callback start
|
|
1716
|
+
this._autoClearTimer = null;
|
|
1717
|
+
this._isClearing = false;
|
|
1718
|
+
}, 5000);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
else {
|
|
1722
|
+
// Check again after delay
|
|
1723
|
+
if (!this._isStopped) {
|
|
1724
|
+
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
// Start checking after a short delay
|
|
1729
|
+
if (!this._isStopped) {
|
|
1730
|
+
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Sends input directly to the PTY process.
|
|
1736
|
+
*
|
|
1737
|
+
* For interactive sessions, this is how you send user input to Claude.
|
|
1738
|
+
* Remember to include `\r` (carriage return) to simulate pressing Enter.
|
|
1739
|
+
*
|
|
1740
|
+
* @param data - The input data to send (text, escape sequences, etc.)
|
|
1741
|
+
*
|
|
1742
|
+
* @example
|
|
1743
|
+
* ```typescript
|
|
1744
|
+
* session.write('hello world'); // Text only, no Enter
|
|
1745
|
+
* session.write('\r'); // Enter key
|
|
1746
|
+
* session.write('ls -la\r'); // Command with Enter
|
|
1747
|
+
* ```
|
|
1748
|
+
*/
|
|
1749
|
+
write(data) {
|
|
1750
|
+
if (this.ptyProcess) {
|
|
1751
|
+
this.ptyProcess.write(data);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Sends input via the terminal multiplexer's direct input mechanism.
|
|
1756
|
+
*
|
|
1757
|
+
* More reliable than direct PTY write for programmatic input, especially
|
|
1758
|
+
* with Claude CLI which uses Ink (React for terminals).
|
|
1759
|
+
* Uses tmux `send-keys -l` to inject text + Enter.
|
|
1760
|
+
*
|
|
1761
|
+
* @param data - Input data with optional `\r` for Enter
|
|
1762
|
+
* @returns true if input was sent, false if no mux session or PTY
|
|
1763
|
+
*
|
|
1764
|
+
* @example
|
|
1765
|
+
* ```typescript
|
|
1766
|
+
* session.writeViaMux('/clear\r'); // Send /clear command
|
|
1767
|
+
* session.writeViaMux('/init\r'); // Send /init command
|
|
1768
|
+
* ```
|
|
1769
|
+
*/
|
|
1770
|
+
async writeViaMux(data) {
|
|
1771
|
+
if (this._mux && this._muxSession) {
|
|
1772
|
+
return this._mux.sendInput(this.id, data);
|
|
1773
|
+
}
|
|
1774
|
+
// Fallback to PTY write
|
|
1775
|
+
if (this.ptyProcess) {
|
|
1776
|
+
this.ptyProcess.write(data);
|
|
1777
|
+
return true;
|
|
1778
|
+
}
|
|
1779
|
+
return false;
|
|
1780
|
+
}
|
|
1781
|
+
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
|
1782
|
+
_ptyCols = 120;
|
|
1783
|
+
_ptyRows = 40;
|
|
1784
|
+
/**
|
|
1785
|
+
* Resizes the PTY terminal dimensions.
|
|
1786
|
+
* Skips the resize if dimensions haven't changed to avoid triggering
|
|
1787
|
+
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
|
|
1788
|
+
*
|
|
1789
|
+
* @param cols - Number of columns (width in characters)
|
|
1790
|
+
* @param rows - Number of rows (height in lines)
|
|
1791
|
+
*/
|
|
1792
|
+
resize(cols, rows) {
|
|
1793
|
+
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
|
1794
|
+
this._ptyCols = cols;
|
|
1795
|
+
this._ptyRows = rows;
|
|
1796
|
+
this.ptyProcess.resize(cols, rows);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
// Legacy method for compatibility with session-manager
|
|
1800
|
+
async start() {
|
|
1801
|
+
this._status = 'idle';
|
|
1802
|
+
}
|
|
1803
|
+
// Legacy method for sending input - wraps runPrompt
|
|
1804
|
+
async sendInput(input) {
|
|
1805
|
+
this._status = 'busy';
|
|
1806
|
+
this._lastActivityAt = Date.now();
|
|
1807
|
+
this.runPrompt(input).catch((err) => {
|
|
1808
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1809
|
+
// Clean up task state so the task queue doesn't get stuck
|
|
1810
|
+
if (this._currentTaskId) {
|
|
1811
|
+
const taskId = this._currentTaskId;
|
|
1812
|
+
this._currentTaskId = null;
|
|
1813
|
+
this._status = 'idle';
|
|
1814
|
+
this._lastActivityAt = Date.now();
|
|
1815
|
+
this.emit('taskError', taskId, errorMsg);
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
this._status = 'idle';
|
|
1819
|
+
}
|
|
1820
|
+
this.emit('error', errorMsg);
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Remove event listeners from TaskTracker and RalphTracker.
|
|
1825
|
+
* Prevents memory leaks by ensuring handlers don't persist after session stop.
|
|
1826
|
+
*/
|
|
1827
|
+
cleanupTrackerListeners() {
|
|
1828
|
+
// Remove TaskTracker handlers
|
|
1829
|
+
if (this._taskTrackerHandlers) {
|
|
1830
|
+
this._taskTracker.off('taskCreated', this._taskTrackerHandlers.taskCreated);
|
|
1831
|
+
this._taskTracker.off('taskUpdated', this._taskTrackerHandlers.taskUpdated);
|
|
1832
|
+
this._taskTracker.off('taskCompleted', this._taskTrackerHandlers.taskCompleted);
|
|
1833
|
+
this._taskTracker.off('taskFailed', this._taskTrackerHandlers.taskFailed);
|
|
1834
|
+
this._taskTrackerHandlers = null;
|
|
1835
|
+
}
|
|
1836
|
+
// Remove RalphTracker handlers
|
|
1837
|
+
if (this._ralphHandlers) {
|
|
1838
|
+
this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate);
|
|
1839
|
+
this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate);
|
|
1840
|
+
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
|
|
1841
|
+
this._ralphTracker.off('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
|
|
1842
|
+
this._ralphTracker.off('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
|
|
1843
|
+
this._ralphTracker.off('exitGateMet', this._ralphHandlers.exitGateMet);
|
|
1844
|
+
this._ralphHandlers = null;
|
|
1845
|
+
}
|
|
1846
|
+
// Remove BashToolParser handlers
|
|
1847
|
+
if (this._bashToolHandlers) {
|
|
1848
|
+
this._bashToolParser.off('toolStart', this._bashToolHandlers.toolStart);
|
|
1849
|
+
this._bashToolParser.off('toolEnd', this._bashToolHandlers.toolEnd);
|
|
1850
|
+
this._bashToolParser.off('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
|
1851
|
+
this._bashToolHandlers = null;
|
|
1852
|
+
}
|
|
1853
|
+
// Destroy all trackers to release memory and stop timers
|
|
1854
|
+
this._bashToolParser.destroy();
|
|
1855
|
+
this._taskTracker.destroy();
|
|
1856
|
+
this._ralphTracker.destroy();
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Stops the session and cleans up resources.
|
|
1860
|
+
*
|
|
1861
|
+
* This kills the PTY process and optionally the associated tmux session.
|
|
1862
|
+
* All buffers are cleared and the session is marked as stopped.
|
|
1863
|
+
*
|
|
1864
|
+
* @param killMux - Whether to also kill the mux session (default: true)
|
|
1865
|
+
*
|
|
1866
|
+
* @example
|
|
1867
|
+
* ```typescript
|
|
1868
|
+
* // Stop and kill everything
|
|
1869
|
+
* await session.stop();
|
|
1870
|
+
*
|
|
1871
|
+
* // Stop but keep mux session running for later reattachment
|
|
1872
|
+
* await session.stop(false);
|
|
1873
|
+
* ```
|
|
1874
|
+
*/
|
|
1875
|
+
async stop(killMux = true) {
|
|
1876
|
+
// Set stopped flag first to prevent new timers from being created
|
|
1877
|
+
this._isStopped = true;
|
|
1878
|
+
// Clear activity timeout to prevent memory leak
|
|
1879
|
+
if (this.activityTimeout) {
|
|
1880
|
+
clearTimeout(this.activityTimeout);
|
|
1881
|
+
this.activityTimeout = null;
|
|
1882
|
+
}
|
|
1883
|
+
// Clear line buffer flush timer
|
|
1884
|
+
if (this._lineBufferFlushTimer) {
|
|
1885
|
+
clearTimeout(this._lineBufferFlushTimer);
|
|
1886
|
+
this._lineBufferFlushTimer = null;
|
|
1887
|
+
}
|
|
1888
|
+
// Clear auto-compact/auto-clear timers to prevent memory leaks
|
|
1889
|
+
if (this._autoCompactTimer) {
|
|
1890
|
+
clearTimeout(this._autoCompactTimer);
|
|
1891
|
+
this._autoCompactTimer = null;
|
|
1892
|
+
}
|
|
1893
|
+
this._isCompacting = false;
|
|
1894
|
+
if (this._autoClearTimer) {
|
|
1895
|
+
clearTimeout(this._autoClearTimer);
|
|
1896
|
+
this._autoClearTimer = null;
|
|
1897
|
+
}
|
|
1898
|
+
this._isClearing = false;
|
|
1899
|
+
// Clear prompt check timers
|
|
1900
|
+
if (this._promptCheckInterval) {
|
|
1901
|
+
clearInterval(this._promptCheckInterval);
|
|
1902
|
+
this._promptCheckInterval = null;
|
|
1903
|
+
}
|
|
1904
|
+
if (this._promptCheckTimeout) {
|
|
1905
|
+
clearTimeout(this._promptCheckTimeout);
|
|
1906
|
+
this._promptCheckTimeout = null;
|
|
1907
|
+
}
|
|
1908
|
+
// Clear shell idle timer
|
|
1909
|
+
if (this._shellIdleTimer) {
|
|
1910
|
+
clearTimeout(this._shellIdleTimer);
|
|
1911
|
+
this._shellIdleTimer = null;
|
|
1912
|
+
}
|
|
1913
|
+
// Clear expensive processing timer
|
|
1914
|
+
if (this._expensiveProcessTimer) {
|
|
1915
|
+
clearTimeout(this._expensiveProcessTimer);
|
|
1916
|
+
this._expensiveProcessTimer = null;
|
|
1917
|
+
}
|
|
1918
|
+
this._pendingCleanData = '';
|
|
1919
|
+
// Immediately cleanup Promise callbacks to prevent orphaned references
|
|
1920
|
+
// during the rest of stop() processing (e.g., if mux kill times out)
|
|
1921
|
+
if (this.rejectPromise && !this._promptResolved) {
|
|
1922
|
+
this._promptResolved = true;
|
|
1923
|
+
this.rejectPromise(new Error('Session stopped'));
|
|
1924
|
+
}
|
|
1925
|
+
this.resolvePromise = null;
|
|
1926
|
+
this.rejectPromise = null;
|
|
1927
|
+
// Remove event listeners from trackers to prevent memory leaks
|
|
1928
|
+
this.cleanupTrackerListeners();
|
|
1929
|
+
if (this.ptyProcess) {
|
|
1930
|
+
if (killMux) {
|
|
1931
|
+
// Full kill: SIGTERM → wait → SIGKILL the PTY and its children
|
|
1932
|
+
const pid = this.ptyProcess.pid;
|
|
1933
|
+
// First try graceful SIGTERM
|
|
1934
|
+
try {
|
|
1935
|
+
this.ptyProcess.kill();
|
|
1936
|
+
}
|
|
1937
|
+
catch (err) {
|
|
1938
|
+
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
|
1939
|
+
}
|
|
1940
|
+
// Give it a moment to terminate gracefully
|
|
1941
|
+
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
|
1942
|
+
// Force kill with SIGKILL if still alive
|
|
1943
|
+
try {
|
|
1944
|
+
if (pid) {
|
|
1945
|
+
process.kill(pid, 'SIGKILL');
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
catch (err) {
|
|
1949
|
+
console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err);
|
|
1950
|
+
}
|
|
1951
|
+
// Also try to kill any child processes in the process group
|
|
1952
|
+
try {
|
|
1953
|
+
if (pid) {
|
|
1954
|
+
process.kill(-pid, 'SIGKILL');
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
catch (err) {
|
|
1958
|
+
console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
else {
|
|
1962
|
+
// Server shutdown: just detach — the process lives on inside tmux
|
|
1963
|
+
console.log('[Session] Detaching from PTY (server shutdown) — mux session preserved');
|
|
1964
|
+
}
|
|
1965
|
+
this.ptyProcess = null;
|
|
1966
|
+
}
|
|
1967
|
+
this._pid = null;
|
|
1968
|
+
this._status = killMux ? 'stopped' : 'idle';
|
|
1969
|
+
this._currentTaskId = null;
|
|
1970
|
+
// Clear task description cache and agent tree to prevent memory leak
|
|
1971
|
+
this._recentTaskDescriptions.clear();
|
|
1972
|
+
this._childAgentIds = [];
|
|
1973
|
+
// Kill the associated mux session if requested
|
|
1974
|
+
if (killMux && this._mux) {
|
|
1975
|
+
// Try to kill mux session even if _muxSession is not set (e.g., restored sessions)
|
|
1976
|
+
try {
|
|
1977
|
+
const killed = await this._mux.killSession(this.id);
|
|
1978
|
+
if (killed) {
|
|
1979
|
+
console.log('[Session] Killed mux session for:', this.id);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
catch (err) {
|
|
1983
|
+
console.error('[Session] Failed to kill mux session:', err);
|
|
1984
|
+
}
|
|
1985
|
+
this._muxSession = null;
|
|
1986
|
+
}
|
|
1987
|
+
else if (this._muxSession && !killMux) {
|
|
1988
|
+
console.log('[Session] Keeping mux session alive:', this._muxSession.muxName);
|
|
1989
|
+
this._muxSession = null; // Detach but don't kill
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
assignTask(taskId) {
|
|
1993
|
+
this._currentTaskId = taskId;
|
|
1994
|
+
this._status = 'busy';
|
|
1995
|
+
this._terminalBuffer.clear();
|
|
1996
|
+
this._textOutput.clear();
|
|
1997
|
+
this._errorBuffer = '';
|
|
1998
|
+
this._messages = [];
|
|
1999
|
+
this._lastActivityAt = Date.now();
|
|
2000
|
+
}
|
|
2001
|
+
clearTask() {
|
|
2002
|
+
this._currentTaskId = null;
|
|
2003
|
+
this._status = 'idle';
|
|
2004
|
+
this._lastActivityAt = Date.now();
|
|
2005
|
+
}
|
|
2006
|
+
getOutput() {
|
|
2007
|
+
return this._textOutput.value;
|
|
2008
|
+
}
|
|
2009
|
+
getError() {
|
|
2010
|
+
return this._errorBuffer;
|
|
2011
|
+
}
|
|
2012
|
+
getTerminalBuffer() {
|
|
2013
|
+
return this._terminalBuffer.value;
|
|
2014
|
+
}
|
|
2015
|
+
clearBuffers() {
|
|
2016
|
+
this._terminalBuffer.clear();
|
|
2017
|
+
this._textOutput.clear();
|
|
2018
|
+
this._errorBuffer = '';
|
|
2019
|
+
this._messages = [];
|
|
2020
|
+
this._taskTracker.clear();
|
|
2021
|
+
this._ralphTracker.clear();
|
|
2022
|
+
this._recentTaskDescriptions.clear();
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
//# sourceMappingURL=session.js.map
|