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.
Files changed (246) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +403 -0
  3. package/dist/ai-checker-base.d.ts +175 -0
  4. package/dist/ai-checker-base.d.ts.map +1 -0
  5. package/dist/ai-checker-base.js +424 -0
  6. package/dist/ai-checker-base.js.map +1 -0
  7. package/dist/ai-idle-checker.d.ts +53 -0
  8. package/dist/ai-idle-checker.d.ts.map +1 -0
  9. package/dist/ai-idle-checker.js +141 -0
  10. package/dist/ai-idle-checker.js.map +1 -0
  11. package/dist/ai-plan-checker.d.ts +52 -0
  12. package/dist/ai-plan-checker.d.ts.map +1 -0
  13. package/dist/ai-plan-checker.js +103 -0
  14. package/dist/ai-plan-checker.js.map +1 -0
  15. package/dist/bash-tool-parser.d.ts +191 -0
  16. package/dist/bash-tool-parser.d.ts.map +1 -0
  17. package/dist/bash-tool-parser.js +598 -0
  18. package/dist/bash-tool-parser.js.map +1 -0
  19. package/dist/cli.d.ts +12 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +460 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config/buffer-limits.d.ts +59 -0
  24. package/dist/config/buffer-limits.d.ts.map +1 -0
  25. package/dist/config/buffer-limits.js +74 -0
  26. package/dist/config/buffer-limits.js.map +1 -0
  27. package/dist/config/map-limits.d.ts +40 -0
  28. package/dist/config/map-limits.d.ts.map +1 -0
  29. package/dist/config/map-limits.js +52 -0
  30. package/dist/config/map-limits.js.map +1 -0
  31. package/dist/file-stream-manager.d.ts +148 -0
  32. package/dist/file-stream-manager.d.ts.map +1 -0
  33. package/dist/file-stream-manager.js +351 -0
  34. package/dist/file-stream-manager.js.map +1 -0
  35. package/dist/hooks-config.d.ts +31 -0
  36. package/dist/hooks-config.d.ts.map +1 -0
  37. package/dist/hooks-config.js +115 -0
  38. package/dist/hooks-config.js.map +1 -0
  39. package/dist/image-watcher.d.ts +86 -0
  40. package/dist/image-watcher.d.ts.map +1 -0
  41. package/dist/image-watcher.js +275 -0
  42. package/dist/image-watcher.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +54 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/mux-factory.d.ts +13 -0
  48. package/dist/mux-factory.d.ts.map +1 -0
  49. package/dist/mux-factory.js +19 -0
  50. package/dist/mux-factory.js.map +1 -0
  51. package/dist/mux-interface.d.ts +145 -0
  52. package/dist/mux-interface.d.ts.map +1 -0
  53. package/dist/mux-interface.js +9 -0
  54. package/dist/mux-interface.js.map +1 -0
  55. package/dist/plan-orchestrator.d.ts +123 -0
  56. package/dist/plan-orchestrator.d.ts.map +1 -0
  57. package/dist/plan-orchestrator.js +500 -0
  58. package/dist/plan-orchestrator.js.map +1 -0
  59. package/dist/prompts/index.d.ts +9 -0
  60. package/dist/prompts/index.d.ts.map +1 -0
  61. package/dist/prompts/index.js +9 -0
  62. package/dist/prompts/index.js.map +1 -0
  63. package/dist/prompts/planner.d.ts +14 -0
  64. package/dist/prompts/planner.d.ts.map +1 -0
  65. package/dist/prompts/planner.js +83 -0
  66. package/dist/prompts/planner.js.map +1 -0
  67. package/dist/prompts/research-agent.d.ts +10 -0
  68. package/dist/prompts/research-agent.d.ts.map +1 -0
  69. package/dist/prompts/research-agent.js +143 -0
  70. package/dist/prompts/research-agent.js.map +1 -0
  71. package/dist/push-store.d.ts +41 -0
  72. package/dist/push-store.d.ts.map +1 -0
  73. package/dist/push-store.js +168 -0
  74. package/dist/push-store.js.map +1 -0
  75. package/dist/ralph-config.d.ts +67 -0
  76. package/dist/ralph-config.d.ts.map +1 -0
  77. package/dist/ralph-config.js +134 -0
  78. package/dist/ralph-config.js.map +1 -0
  79. package/dist/ralph-loop.d.ts +124 -0
  80. package/dist/ralph-loop.d.ts.map +1 -0
  81. package/dist/ralph-loop.js +418 -0
  82. package/dist/ralph-loop.js.map +1 -0
  83. package/dist/ralph-tracker.d.ts +1081 -0
  84. package/dist/ralph-tracker.d.ts.map +1 -0
  85. package/dist/ralph-tracker.js +3343 -0
  86. package/dist/ralph-tracker.js.map +1 -0
  87. package/dist/respawn-controller.d.ts +1182 -0
  88. package/dist/respawn-controller.d.ts.map +1 -0
  89. package/dist/respawn-controller.js +2754 -0
  90. package/dist/respawn-controller.js.map +1 -0
  91. package/dist/run-summary.d.ts +123 -0
  92. package/dist/run-summary.d.ts.map +1 -0
  93. package/dist/run-summary.js +325 -0
  94. package/dist/run-summary.js.map +1 -0
  95. package/dist/session-lifecycle-log.d.ts +36 -0
  96. package/dist/session-lifecycle-log.d.ts.map +1 -0
  97. package/dist/session-lifecycle-log.js +101 -0
  98. package/dist/session-lifecycle-log.js.map +1 -0
  99. package/dist/session-manager.d.ts +97 -0
  100. package/dist/session-manager.d.ts.map +1 -0
  101. package/dist/session-manager.js +224 -0
  102. package/dist/session-manager.js.map +1 -0
  103. package/dist/session.d.ts +686 -0
  104. package/dist/session.d.ts.map +1 -0
  105. package/dist/session.js +2025 -0
  106. package/dist/session.js.map +1 -0
  107. package/dist/state-store.d.ts +189 -0
  108. package/dist/state-store.d.ts.map +1 -0
  109. package/dist/state-store.js +730 -0
  110. package/dist/state-store.js.map +1 -0
  111. package/dist/subagent-watcher.d.ts +345 -0
  112. package/dist/subagent-watcher.d.ts.map +1 -0
  113. package/dist/subagent-watcher.js +1469 -0
  114. package/dist/subagent-watcher.js.map +1 -0
  115. package/dist/task-queue.d.ts +108 -0
  116. package/dist/task-queue.d.ts.map +1 -0
  117. package/dist/task-queue.js +235 -0
  118. package/dist/task-queue.js.map +1 -0
  119. package/dist/task-tracker.d.ts +306 -0
  120. package/dist/task-tracker.d.ts.map +1 -0
  121. package/dist/task-tracker.js +488 -0
  122. package/dist/task-tracker.js.map +1 -0
  123. package/dist/task.d.ts +73 -0
  124. package/dist/task.d.ts.map +1 -0
  125. package/dist/task.js +177 -0
  126. package/dist/task.js.map +1 -0
  127. package/dist/team-watcher.d.ts +53 -0
  128. package/dist/team-watcher.d.ts.map +1 -0
  129. package/dist/team-watcher.js +313 -0
  130. package/dist/team-watcher.js.map +1 -0
  131. package/dist/templates/case-template.md +461 -0
  132. package/dist/templates/claude-md.d.ts +26 -0
  133. package/dist/templates/claude-md.d.ts.map +1 -0
  134. package/dist/templates/claude-md.js +74 -0
  135. package/dist/templates/claude-md.js.map +1 -0
  136. package/dist/tmux-manager.d.ts +181 -0
  137. package/dist/tmux-manager.d.ts.map +1 -0
  138. package/dist/tmux-manager.js +1405 -0
  139. package/dist/tmux-manager.js.map +1 -0
  140. package/dist/transcript-watcher.d.ts +110 -0
  141. package/dist/transcript-watcher.d.ts.map +1 -0
  142. package/dist/transcript-watcher.js +338 -0
  143. package/dist/transcript-watcher.js.map +1 -0
  144. package/dist/tunnel-manager.d.ts +54 -0
  145. package/dist/tunnel-manager.d.ts.map +1 -0
  146. package/dist/tunnel-manager.js +251 -0
  147. package/dist/tunnel-manager.js.map +1 -0
  148. package/dist/types.d.ts +1139 -0
  149. package/dist/types.d.ts.map +1 -0
  150. package/dist/types.js +215 -0
  151. package/dist/types.js.map +1 -0
  152. package/dist/utils/buffer-accumulator.d.ts +111 -0
  153. package/dist/utils/buffer-accumulator.d.ts.map +1 -0
  154. package/dist/utils/buffer-accumulator.js +172 -0
  155. package/dist/utils/buffer-accumulator.js.map +1 -0
  156. package/dist/utils/claude-cli-resolver.d.ts +26 -0
  157. package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
  158. package/dist/utils/claude-cli-resolver.js +78 -0
  159. package/dist/utils/claude-cli-resolver.js.map +1 -0
  160. package/dist/utils/cleanup-manager.d.ts +165 -0
  161. package/dist/utils/cleanup-manager.d.ts.map +1 -0
  162. package/dist/utils/cleanup-manager.js +274 -0
  163. package/dist/utils/cleanup-manager.js.map +1 -0
  164. package/dist/utils/index.d.ts +19 -0
  165. package/dist/utils/index.d.ts.map +1 -0
  166. package/dist/utils/index.js +19 -0
  167. package/dist/utils/index.js.map +1 -0
  168. package/dist/utils/lru-map.d.ts +140 -0
  169. package/dist/utils/lru-map.d.ts.map +1 -0
  170. package/dist/utils/lru-map.js +234 -0
  171. package/dist/utils/lru-map.js.map +1 -0
  172. package/dist/utils/nice-wrapper.d.ts +13 -0
  173. package/dist/utils/nice-wrapper.d.ts.map +1 -0
  174. package/dist/utils/nice-wrapper.js +17 -0
  175. package/dist/utils/nice-wrapper.js.map +1 -0
  176. package/dist/utils/opencode-cli-resolver.d.ts +21 -0
  177. package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
  178. package/dist/utils/opencode-cli-resolver.js +67 -0
  179. package/dist/utils/opencode-cli-resolver.js.map +1 -0
  180. package/dist/utils/regex-patterns.d.ts +64 -0
  181. package/dist/utils/regex-patterns.d.ts.map +1 -0
  182. package/dist/utils/regex-patterns.js +74 -0
  183. package/dist/utils/regex-patterns.js.map +1 -0
  184. package/dist/utils/stale-expiration-map.d.ts +159 -0
  185. package/dist/utils/stale-expiration-map.d.ts.map +1 -0
  186. package/dist/utils/stale-expiration-map.js +277 -0
  187. package/dist/utils/stale-expiration-map.js.map +1 -0
  188. package/dist/utils/string-similarity.d.ts +108 -0
  189. package/dist/utils/string-similarity.d.ts.map +1 -0
  190. package/dist/utils/string-similarity.js +189 -0
  191. package/dist/utils/string-similarity.js.map +1 -0
  192. package/dist/utils/token-validation.d.ts +39 -0
  193. package/dist/utils/token-validation.d.ts.map +1 -0
  194. package/dist/utils/token-validation.js +59 -0
  195. package/dist/utils/token-validation.js.map +1 -0
  196. package/dist/utils/type-safety.d.ts +33 -0
  197. package/dist/utils/type-safety.d.ts.map +1 -0
  198. package/dist/utils/type-safety.js +35 -0
  199. package/dist/utils/type-safety.js.map +1 -0
  200. package/dist/web/public/app.js +491 -0
  201. package/dist/web/public/app.js.br +0 -0
  202. package/dist/web/public/app.js.gz +0 -0
  203. package/dist/web/public/index.html +1675 -0
  204. package/dist/web/public/index.html.br +0 -0
  205. package/dist/web/public/index.html.gz +0 -0
  206. package/dist/web/public/manifest.json +8 -0
  207. package/dist/web/public/mobile.css +1 -0
  208. package/dist/web/public/mobile.css.br +0 -0
  209. package/dist/web/public/mobile.css.gz +0 -0
  210. package/dist/web/public/ralph-wizard.js +1037 -0
  211. package/dist/web/public/ralph-wizard.js.br +0 -0
  212. package/dist/web/public/ralph-wizard.js.gz +0 -0
  213. package/dist/web/public/styles.css +1 -0
  214. package/dist/web/public/styles.css.br +0 -0
  215. package/dist/web/public/styles.css.gz +0 -0
  216. package/dist/web/public/sw.js +67 -0
  217. package/dist/web/public/sw.js.br +0 -0
  218. package/dist/web/public/sw.js.gz +0 -0
  219. package/dist/web/public/upload.html +155 -0
  220. package/dist/web/public/upload.html.br +0 -0
  221. package/dist/web/public/upload.html.gz +0 -0
  222. package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
  223. package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
  224. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  225. package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
  226. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
  227. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  228. package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
  229. package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
  230. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  231. package/dist/web/public/vendor/xterm.css +209 -0
  232. package/dist/web/public/vendor/xterm.css.br +0 -0
  233. package/dist/web/public/vendor/xterm.css.gz +0 -0
  234. package/dist/web/public/vendor/xterm.min.js +9 -0
  235. package/dist/web/public/vendor/xterm.min.js.br +0 -0
  236. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  237. package/dist/web/schemas.d.ts +479 -0
  238. package/dist/web/schemas.d.ts.map +1 -0
  239. package/dist/web/schemas.js +448 -0
  240. package/dist/web/schemas.js.map +1 -0
  241. package/dist/web/server.d.ts +207 -0
  242. package/dist/web/server.d.ts.map +1 -0
  243. package/dist/web/server.js +5784 -0
  244. package/dist/web/server.js.map +1 -0
  245. package/package.json +110 -0
  246. package/scripts/postinstall.js +390 -0
@@ -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