aegis-bridge 2.2.2

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
  4. package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +219 -0
  8. package/dist/channels/index.d.ts +8 -0
  9. package/dist/channels/index.js +9 -0
  10. package/dist/channels/manager.d.ts +39 -0
  11. package/dist/channels/manager.js +101 -0
  12. package/dist/channels/telegram-style.d.ts +118 -0
  13. package/dist/channels/telegram-style.js +203 -0
  14. package/dist/channels/telegram.d.ts +76 -0
  15. package/dist/channels/telegram.js +1396 -0
  16. package/dist/channels/types.d.ts +77 -0
  17. package/dist/channels/types.js +9 -0
  18. package/dist/channels/webhook.d.ts +58 -0
  19. package/dist/channels/webhook.js +162 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.js +223 -0
  22. package/dist/config.d.ts +60 -0
  23. package/dist/config.js +188 -0
  24. package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
  25. package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
  26. package/dist/dashboard/index.html +14 -0
  27. package/dist/events.d.ts +86 -0
  28. package/dist/events.js +258 -0
  29. package/dist/hook-settings.d.ts +67 -0
  30. package/dist/hook-settings.js +138 -0
  31. package/dist/hook.d.ts +18 -0
  32. package/dist/hook.js +199 -0
  33. package/dist/hooks.d.ts +32 -0
  34. package/dist/hooks.js +279 -0
  35. package/dist/jsonl-watcher.d.ts +57 -0
  36. package/dist/jsonl-watcher.js +159 -0
  37. package/dist/mcp-server.d.ts +60 -0
  38. package/dist/mcp-server.js +788 -0
  39. package/dist/metrics.d.ts +104 -0
  40. package/dist/metrics.js +226 -0
  41. package/dist/monitor.d.ts +84 -0
  42. package/dist/monitor.js +553 -0
  43. package/dist/permission-guard.d.ts +51 -0
  44. package/dist/permission-guard.js +197 -0
  45. package/dist/pipeline.d.ts +84 -0
  46. package/dist/pipeline.js +218 -0
  47. package/dist/screenshot.d.ts +26 -0
  48. package/dist/screenshot.js +57 -0
  49. package/dist/server.d.ts +10 -0
  50. package/dist/server.js +1577 -0
  51. package/dist/session.d.ts +297 -0
  52. package/dist/session.js +1275 -0
  53. package/dist/sse-limiter.d.ts +47 -0
  54. package/dist/sse-limiter.js +62 -0
  55. package/dist/sse-writer.d.ts +31 -0
  56. package/dist/sse-writer.js +95 -0
  57. package/dist/ssrf.d.ts +57 -0
  58. package/dist/ssrf.js +169 -0
  59. package/dist/swarm-monitor.d.ts +114 -0
  60. package/dist/swarm-monitor.js +267 -0
  61. package/dist/terminal-parser.d.ts +16 -0
  62. package/dist/terminal-parser.js +343 -0
  63. package/dist/tmux.d.ts +161 -0
  64. package/dist/tmux.js +725 -0
  65. package/dist/transcript.d.ts +47 -0
  66. package/dist/transcript.js +244 -0
  67. package/dist/validation.d.ts +222 -0
  68. package/dist/validation.js +268 -0
  69. package/dist/ws-terminal.d.ts +32 -0
  70. package/dist/ws-terminal.js +297 -0
  71. package/package.json +71 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * swarm-monitor.ts — Monitors Claude Code swarm sockets for teammate sessions.
3
+ *
4
+ * Issue #81: Agent Swarm Awareness.
5
+ *
6
+ * When CC spawns teammates/subagents, it creates them in tmux with:
7
+ * - Socket: -L claude-swarm-{pid} (isolated from main session)
8
+ * - Window naming: teammate-{name}
9
+ * - Env vars: CLAUDE_PARENT_SESSION_ID, --agent-id, --agent-name
10
+ *
11
+ * This module discovers those swarm sockets, lists their windows,
12
+ * cross-references with parent sessions, and tracks teammate status.
13
+ */
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import { readdir } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ const execFileAsync = promisify(execFile);
19
+ const TMUX_TIMEOUT_MS = 5_000;
20
+ export const DEFAULT_SWARM_CONFIG = {
21
+ scanIntervalMs: 10_000,
22
+ socketGlobPattern: 'tmux-claude-swarm-*',
23
+ };
24
+ export class SwarmMonitor {
25
+ sessions;
26
+ config;
27
+ running = false;
28
+ lastResult = null;
29
+ timer = null;
30
+ eventHandlers = [];
31
+ constructor(sessions, config = DEFAULT_SWARM_CONFIG) {
32
+ this.sessions = sessions;
33
+ this.config = config;
34
+ }
35
+ /** Register an event handler for teammate lifecycle events. */
36
+ onEvent(handler) {
37
+ this.eventHandlers.push(handler);
38
+ }
39
+ emitEvent(event) {
40
+ for (const handler of this.eventHandlers) {
41
+ try {
42
+ handler(event);
43
+ }
44
+ catch (e) {
45
+ console.error('SwarmMonitor event handler error:', e);
46
+ }
47
+ }
48
+ }
49
+ /** Start the periodic scan loop. */
50
+ start() {
51
+ if (this.running)
52
+ return;
53
+ this.running = true;
54
+ void this.scan();
55
+ this.timer = setInterval(() => {
56
+ void this.scan();
57
+ }, this.config.scanIntervalMs);
58
+ }
59
+ /** Stop the periodic scan loop. */
60
+ stop() {
61
+ this.running = false;
62
+ if (this.timer) {
63
+ clearInterval(this.timer);
64
+ this.timer = null;
65
+ }
66
+ }
67
+ /** Get the most recent scan result. */
68
+ getLastResult() {
69
+ return this.lastResult;
70
+ }
71
+ /** Run a single scan and return the result. */
72
+ async scan() {
73
+ try {
74
+ const sockets = await this.discoverSwarmSockets();
75
+ // Issue #353: Inspect sockets in parallel to avoid N×timeout accumulation.
76
+ const results = await Promise.allSettled(sockets.map(socketName => this.inspectSwarmSocket(socketName)));
77
+ const swarms = [];
78
+ for (const result of results) {
79
+ if (result.status === 'fulfilled') {
80
+ swarms.push(result.value);
81
+ }
82
+ }
83
+ this.lastResult = {
84
+ swarms,
85
+ totalSockets: sockets.length,
86
+ totalTeammates: swarms.reduce((sum, s) => sum + s.teammates.length, 0),
87
+ scannedAt: Date.now(),
88
+ };
89
+ this.detectChanges();
90
+ return this.lastResult;
91
+ }
92
+ catch (e) {
93
+ // Issue #353: Prevent unhandled rejection from setInterval fire-and-forget.
94
+ console.error('SwarmMonitor scan error:', e);
95
+ return this.lastResult ?? {
96
+ swarms: [],
97
+ totalSockets: 0,
98
+ totalTeammates: 0,
99
+ scannedAt: Date.now(),
100
+ };
101
+ }
102
+ }
103
+ /** Compare current scan result against previous to detect teammate changes. */
104
+ detectChanges() {
105
+ if (!this.lastResult)
106
+ return;
107
+ for (const swarm of this.lastResult.swarms) {
108
+ // Issue #353: Always update previous snapshot, even without a parent session,
109
+ // to prevent repeated spawn events on every scan cycle.
110
+ const prevSwarm = this.previousTeammates.get(swarm.socketName);
111
+ this.previousTeammates.set(swarm.socketName, swarm.teammates.map(t => ({ ...t })));
112
+ if (!swarm.parentSession)
113
+ continue;
114
+ const prevNames = new Set(prevSwarm?.map(t => t.windowName) ?? []);
115
+ // New teammates
116
+ for (const teammate of swarm.teammates) {
117
+ if (!prevNames.has(teammate.windowName) && teammate.status !== 'dead') {
118
+ this.emitEvent({ type: 'teammate_spawned', swarm, teammate });
119
+ }
120
+ }
121
+ // Finished teammates (previously seen, now dead)
122
+ if (prevSwarm) {
123
+ for (const prev of prevSwarm) {
124
+ const current = swarm.teammates.find(t => t.windowName === prev.windowName);
125
+ if (!current) {
126
+ // Teammate window gone entirely
127
+ this.emitEvent({ type: 'teammate_finished', swarm, teammate: { ...prev, status: 'dead', alive: false } });
128
+ }
129
+ else if (prev.status === 'running' && current.status === 'dead') {
130
+ this.emitEvent({ type: 'teammate_finished', swarm, teammate: current });
131
+ }
132
+ }
133
+ }
134
+ }
135
+ // Clean up stale socket tracking
136
+ for (const socketName of this.previousTeammates.keys()) {
137
+ if (!this.lastResult.swarms.find(s => s.socketName === socketName)) {
138
+ this.previousTeammates.delete(socketName);
139
+ }
140
+ }
141
+ }
142
+ /** Snapshot of teammates from previous scan for diffing. */
143
+ previousTeammates = new Map();
144
+ /** Cached /tmp listing to avoid redundant I/O on every scan. */
145
+ cachedSocketNames = [];
146
+ cachedSocketAt = 0;
147
+ static SOCKET_CACHE_TTL_MS = 5_000;
148
+ /** Discover swarm socket directories in /tmp. */
149
+ async discoverSwarmSockets() {
150
+ try {
151
+ // Issue #353: Cache /tmp listing for 5s to avoid redundant I/O.
152
+ const now = Date.now();
153
+ if (this.cachedSocketNames.length > 0 && now - this.cachedSocketAt < SwarmMonitor.SOCKET_CACHE_TTL_MS) {
154
+ return this.cachedSocketNames;
155
+ }
156
+ const entries = await readdir(tmpdir());
157
+ const pattern = this.config.socketGlobPattern.replace('tmux-', '');
158
+ // Match "tmux-<socketName>" directories (tmux socket dirs start with "tmux-")
159
+ const socketNames = [];
160
+ for (const entry of entries) {
161
+ if (entry.startsWith('tmux-') && entry.includes(pattern)) {
162
+ // Extract socket name: tmux-<socketName> → <socketName>
163
+ const socketName = entry.slice(5); // remove "tmux-"
164
+ // Verify it's a claude-swarm-* socket
165
+ if (socketName.startsWith('claude-swarm-')) {
166
+ socketNames.push(socketName);
167
+ }
168
+ }
169
+ }
170
+ this.cachedSocketNames = socketNames;
171
+ this.cachedSocketAt = now;
172
+ return socketNames;
173
+ }
174
+ catch { /* tmux list-sockets failed — no swarm sockets visible */
175
+ return [];
176
+ }
177
+ }
178
+ /** Inspect a single swarm socket and return swarm info. */
179
+ async inspectSwarmSocket(socketName) {
180
+ const pid = this.extractPid(socketName);
181
+ const teammates = await this.listSwarmWindows(socketName);
182
+ const parentSession = this.findParentSession(pid, teammates);
183
+ const aggregatedStatus = this.computeAggregatedStatus(teammates);
184
+ return {
185
+ socketName,
186
+ pid,
187
+ parentSession,
188
+ teammates,
189
+ aggregatedStatus,
190
+ lastScannedAt: Date.now(),
191
+ };
192
+ }
193
+ /** Extract PID from socket name "claude-swarm-{pid}". */
194
+ extractPid(socketName) {
195
+ const match = socketName.match(/^claude-swarm-(\d+)$/);
196
+ return match ? parseInt(match[1], 10) : 0;
197
+ }
198
+ /** List all windows in a swarm socket. */
199
+ async listSwarmWindows(socketName) {
200
+ try {
201
+ const { stdout } = await execFileAsync('tmux', ['-L', socketName, 'list-windows', '-F', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}'], { timeout: TMUX_TIMEOUT_MS });
202
+ const raw = stdout.trim();
203
+ if (!raw)
204
+ return [];
205
+ return raw.split('\n').filter(Boolean).map(line => {
206
+ const [windowId, windowName, cwd, paneCommand] = line.split('\t');
207
+ const cmd = (paneCommand || '').toLowerCase();
208
+ const alive = cmd === 'claude' || cmd === 'node';
209
+ const status = alive
210
+ ? 'running'
211
+ : (paneCommand === '' || paneCommand === 'bash' || paneCommand === 'zsh')
212
+ ? 'idle'
213
+ : 'dead';
214
+ return {
215
+ windowId: windowId || '',
216
+ windowName: windowName || '',
217
+ cwd: cwd || '',
218
+ paneCommand: paneCommand || '',
219
+ alive,
220
+ status,
221
+ };
222
+ });
223
+ }
224
+ catch {
225
+ // Socket may be stale (parent process died)
226
+ return [];
227
+ }
228
+ }
229
+ /** Find the parent Aegis session for a swarm by matching the CC process PID. */
230
+ findParentSession(pid, _teammates) {
231
+ if (pid === 0)
232
+ return null;
233
+ // Issue #353: Match swarm socket PID against session.ccPid.
234
+ // The swarm socket name (claude-swarm-{pid}) contains the PID of the parent CC process.
235
+ for (const session of this.sessions.listSessions()) {
236
+ if (session.ccPid === pid) {
237
+ return session;
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+ /** Compute aggregated status for a swarm. */
243
+ computeAggregatedStatus(teammates) {
244
+ if (teammates.length === 0)
245
+ return 'no_teammates';
246
+ const allDead = teammates.every(t => t.status === 'dead');
247
+ if (allDead)
248
+ return 'all_dead';
249
+ const anyWorking = teammates.some(t => t.status === 'running');
250
+ if (anyWorking)
251
+ return 'some_working';
252
+ return 'all_idle';
253
+ }
254
+ /** Find a specific swarm by parent session ID. */
255
+ findSwarmByParentSessionId(sessionId) {
256
+ if (!this.lastResult)
257
+ return null;
258
+ return this.lastResult.swarms.find(s => s.parentSession?.id === sessionId) ?? null;
259
+ }
260
+ /** Find all swarms associated with any active session. */
261
+ findActiveSwarms() {
262
+ if (!this.lastResult)
263
+ return [];
264
+ return this.lastResult.swarms.filter(s => s.parentSession !== null && s.teammates.length > 0);
265
+ }
266
+ }
267
+ //# sourceMappingURL=swarm-monitor.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * terminal-parser.ts — Detects Claude Code UI state from tmux pane content.
3
+ *
4
+ * Port of CCBot's terminal_parser.py.
5
+ * Detects: permission prompts, plan mode, ask questions, status line.
6
+ */
7
+ export type UIState = 'idle' | 'working' | 'compacting' | 'context_warning' | 'waiting_for_input' | 'permission_prompt' | 'plan_mode' | 'ask_question' | 'bash_approval' | 'settings' | 'error' | 'unknown';
8
+ /** Detect the UI state from captured pane text. */
9
+ export declare function detectUIState(paneText: string): UIState;
10
+ /** Extract the interactive UI content if present. */
11
+ export declare function extractInteractiveContent(paneText: string): {
12
+ content: string;
13
+ name: UIState;
14
+ } | null;
15
+ /** Parse the status line text (what CC is doing). */
16
+ export declare function parseStatusLine(paneText: string): string | null;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * terminal-parser.ts — Detects Claude Code UI state from tmux pane content.
3
+ *
4
+ * Port of CCBot's terminal_parser.py.
5
+ * Detects: permission prompts, plan mode, ask questions, status line.
6
+ */
7
+ const UI_PATTERNS = [
8
+ {
9
+ name: 'plan_mode',
10
+ top: [
11
+ /^\s*Would you like to proceed\?/,
12
+ /^\s*Claude has written up a plan/,
13
+ ],
14
+ bottom: [
15
+ /^\s*ctrl-g to edit in /,
16
+ /^\s*Esc to (cancel|exit)/,
17
+ ],
18
+ minGap: 2,
19
+ },
20
+ {
21
+ name: 'ask_question',
22
+ top: [/^\s*[☐✔☒]/],
23
+ bottom: [/^\s*Enter to select/],
24
+ minGap: 1,
25
+ },
26
+ {
27
+ name: 'permission_prompt',
28
+ top: [
29
+ /^\s*Do you want to proceed\?/,
30
+ /^\s*Do you want to make this edit/,
31
+ /^\s*Do you want to create \S/,
32
+ /^\s*Do you want to delete \S/,
33
+ /^\s*Do you want to allow Claude to make these changes/, // batch edit
34
+ /^\s*Do you want to allow Claude to use/, // MCP tool
35
+ /^\s*Do you want to trust this (project|workspace)/, // workspace trust
36
+ /^\s*Do you want to allow (reading|writing)/, // file scope
37
+ /^\s*Do you want to run this command/, // alt bash approval
38
+ /^\s*Do you want to allow writing to/, // file write scope
39
+ /^\s*Continue\?/, // continuation
40
+ ],
41
+ bottom: [/^\s*Esc to cancel/],
42
+ minGap: 2,
43
+ },
44
+ {
45
+ name: 'permission_prompt',
46
+ top: [/^\s*❯\s*1\.\s*Yes/],
47
+ bottom: [],
48
+ minGap: 2,
49
+ },
50
+ {
51
+ name: 'bash_approval',
52
+ top: [
53
+ /^\s*Bash command\s*$/,
54
+ /^\s*This command requires approval/,
55
+ ],
56
+ bottom: [/^\s*Esc to cancel/],
57
+ minGap: 2,
58
+ },
59
+ {
60
+ name: 'settings',
61
+ top: [
62
+ /^\s*Settings:.*tab to cycle/,
63
+ /^\s*Select model/,
64
+ ],
65
+ bottom: [
66
+ /^\s*Esc to cancel/,
67
+ /^\s*Esc to exit/,
68
+ /^\s*Enter to confirm/,
69
+ /^\s*Type to filter/,
70
+ ],
71
+ minGap: 2,
72
+ },
73
+ {
74
+ name: 'error',
75
+ top: [
76
+ /^Error:/,
77
+ /Rate limit/,
78
+ /Authentication failed/,
79
+ /overloaded/i,
80
+ /API error/,
81
+ /^429\b/,
82
+ ],
83
+ bottom: [/^\s*❯\s*$/],
84
+ minGap: 1,
85
+ },
86
+ ];
87
+ // Spinner characters Claude Code uses (including braille spinners with TERM=xterm-256color)
88
+ // Issue #102: CC also uses * (asterisk) and ● (bullet) for status lines like "* Perambulating…"
89
+ const STATUS_SPINNERS = new Set([
90
+ '·', '✻', '✽', '✶', '✳', '✢', '*', '●',
91
+ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
92
+ '⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷',
93
+ ]);
94
+ /** Detect the UI state from captured pane text. */
95
+ export function detectUIState(paneText) {
96
+ if (!paneText)
97
+ return 'unknown';
98
+ const lines = paneText.trim().split('\n');
99
+ // Check for interactive UI patterns first (highest priority)
100
+ for (const pattern of UI_PATTERNS) {
101
+ if (tryMatchPattern(lines, pattern)) {
102
+ return pattern.name;
103
+ }
104
+ }
105
+ // Check for working status — scan entire pane for active spinners
106
+ const statusText = parseStatusLine(paneText);
107
+ const hasActiveSpinner = hasSpinnerAnywhere(lines);
108
+ // Check for the prompt (❯) near the bottom
109
+ const hasPrompt = hasIdlePrompt(lines);
110
+ const hasChrome = hasChromeSeparator(lines);
111
+ if (statusText) {
112
+ // "Worked for Xs" = finished, not working; "Aborted" = CC was interrupted
113
+ if (/^Worked for/i.test(statusText) || /^Compacted/i.test(statusText) || /aborted/i.test(statusText)) {
114
+ return hasPrompt ? 'idle' : 'unknown';
115
+ }
116
+ // Active spinner text = working regardless of prompt
117
+ return 'working';
118
+ }
119
+ // Even without parseStatusLine match, if we see active spinners → working
120
+ if (hasActiveSpinner) {
121
+ return 'working';
122
+ }
123
+ // L30: Check for compacting state — CC shows "Compacting..." when compacting context
124
+ // Checked after working so active spinners take priority over compacting text
125
+ const compactingState = detectCompacting(lines);
126
+ if (compactingState)
127
+ return 'compacting';
128
+ // L31: Check for context window warning — CC shows "Context window X% full"
129
+ const contextWarning = detectContextWarning(lines);
130
+ if (contextWarning)
131
+ return 'context_warning';
132
+ if (hasPrompt) {
133
+ // L32: Differentiate idle (chrome separator present) vs waiting_for_input (no chrome)
134
+ return hasChrome ? 'idle' : 'waiting_for_input';
135
+ }
136
+ // Check for chrome separator (─────) near bottom = CC is loaded
137
+ if (hasChrome) {
138
+ return 'idle';
139
+ }
140
+ // L32: Check for waiting-for-input patterns without the idle separator
141
+ if (detectWaitingForInput(lines))
142
+ return 'waiting_for_input';
143
+ return 'unknown';
144
+ }
145
+ /** Check if any line in the pane has an active spinner character followed by working text. */
146
+ function hasSpinnerAnywhere(lines) {
147
+ // Only check lines in the content area (not the very bottom few which are prompt/footer)
148
+ const searchEnd = Math.max(0, lines.length - 3);
149
+ for (let i = Math.max(0, lines.length - 20); i < searchEnd; i++) {
150
+ const stripped = lines[i].trim();
151
+ if (!stripped)
152
+ continue;
153
+ // Check for spinner characters at start of line, followed by text containing "…" or "..."
154
+ const firstChar = stripped[0];
155
+ if (STATUS_SPINNERS.has(firstChar) && stripped.length > 1) {
156
+ // For `*` (also a markdown bullet), require `* ` + ellipsis/dots to avoid false positives
157
+ if (firstChar === '*') {
158
+ if (stripped[1] !== ' ' || !(stripped.includes('…') || stripped.includes('...')))
159
+ continue;
160
+ }
161
+ else if (!(stripped.includes('…') || stripped.includes('...') || /[^\s\u00a0]/.test(stripped.slice(1)))) {
162
+ continue;
163
+ }
164
+ // Exclude "Worked for" which is a completion indicator, and "Aborted" which means CC stopped
165
+ if (/^.Worked for/i.test(stripped) || /^.Compacted/i.test(stripped) || /aborted/i.test(stripped))
166
+ continue;
167
+ return true;
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+ /** Check if the prompt ❯ is visible between chrome separators. */
173
+ function hasIdlePrompt(lines) {
174
+ // Look for ❯ on its own line near the bottom, between two ─── separators
175
+ for (let i = Math.max(0, lines.length - 8); i < lines.length; i++) {
176
+ const stripped = lines[i].trim();
177
+ if (stripped === '❯' || stripped === '❯\u00a0' || stripped.startsWith('❯ ') || stripped.startsWith('❯\u00a0')) {
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+ /** Check if a chrome separator (─────) is present near the bottom of the pane. */
184
+ function hasChromeSeparator(lines) {
185
+ for (let i = Math.max(0, lines.length - 10); i < lines.length; i++) {
186
+ const stripped = lines[i].trim();
187
+ if (stripped.length >= 20 && /^─+$/.test(stripped)) {
188
+ return true;
189
+ }
190
+ }
191
+ return false;
192
+ }
193
+ /** L30: Detect compacting state — CC shows "Compacting..." when compacting context. */
194
+ function detectCompacting(lines) {
195
+ // Check last 15 lines for compacting indicators
196
+ const searchStart = Math.max(0, lines.length - 15);
197
+ for (let i = searchStart; i < lines.length; i++) {
198
+ const line = lines[i].trim();
199
+ if (!line)
200
+ continue;
201
+ if (/compacting/i.test(line) && !/compacted/i.test(line)) {
202
+ return true;
203
+ }
204
+ }
205
+ return false;
206
+ }
207
+ /** L31: Detect context window warning — CC shows "Context window X% full". */
208
+ function detectContextWarning(lines) {
209
+ // Check last 15 lines for context window warnings
210
+ const searchStart = Math.max(0, lines.length - 15);
211
+ for (let i = searchStart; i < lines.length; i++) {
212
+ const line = lines[i].trim();
213
+ if (!line)
214
+ continue;
215
+ if (/context\s+window/i.test(line) && /(\d+)%/.test(line)) {
216
+ return true;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+ /** L32: Detect waiting-for-input state — CC prompt without chrome separator. */
222
+ function detectWaitingForInput(lines) {
223
+ // Look for prompt-like text near the bottom without the chrome separator
224
+ const searchStart = Math.max(0, lines.length - 8);
225
+ for (let i = searchStart; i < lines.length; i++) {
226
+ const stripped = lines[i].trim();
227
+ // ❯ with text (but not bare ❯ which is idle)
228
+ if ((stripped.startsWith('❯ ') || stripped.startsWith('❯\u00a0')) && stripped.length > 2) {
229
+ return true;
230
+ }
231
+ // CC asking questions like "What would you like to do?" near bottom
232
+ if (/^(What would you like|What do you want|How would you like|How should I)/i.test(stripped)) {
233
+ return true;
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+ /** Extract the interactive UI content if present. */
239
+ export function extractInteractiveContent(paneText) {
240
+ if (!paneText)
241
+ return null;
242
+ const lines = paneText.trim().split('\n');
243
+ for (const pattern of UI_PATTERNS) {
244
+ const result = extractPattern(lines, pattern);
245
+ if (result)
246
+ return { content: result, name: pattern.name };
247
+ }
248
+ return null;
249
+ }
250
+ /** Parse the status line text (what CC is doing). */
251
+ export function parseStatusLine(paneText) {
252
+ if (!paneText)
253
+ return null;
254
+ const lines = paneText.split('\n');
255
+ // Find chrome separator
256
+ let chromeIdx = null;
257
+ const searchStart = Math.max(0, lines.length - 10);
258
+ for (let i = searchStart; i < lines.length; i++) {
259
+ const stripped = lines[i].trim();
260
+ if (stripped.length >= 20 && /^─+$/.test(stripped)) {
261
+ chromeIdx = i;
262
+ break;
263
+ }
264
+ }
265
+ if (chromeIdx === null)
266
+ return null;
267
+ // Check lines above separator for spinner
268
+ for (let i = chromeIdx - 1; i > Math.max(chromeIdx - 10, -1); i--) {
269
+ const line = lines[i].trim();
270
+ if (!line)
271
+ continue;
272
+ if (STATUS_SPINNERS.has(line[0])) {
273
+ // For `*`, require `* ` + ellipsis/dots to avoid matching markdown bullets
274
+ if (line[0] === '*' && (line[1] !== ' ' || !(line.includes('…') || line.includes('...')))) {
275
+ // Not a real spinner line — skip
276
+ continue;
277
+ }
278
+ return line.slice(1).trim();
279
+ }
280
+ // Skip non-spinner lines (tool output between spinner and separator) and keep scanning
281
+ }
282
+ return null;
283
+ }
284
+ function tryMatchPattern(lines, pattern) {
285
+ // Only search the last 30 lines to avoid matching scrollback text
286
+ const searchStart = Math.max(0, lines.length - 30);
287
+ // Try each top match — don't give up after the first one fails to find a bottom
288
+ for (let t = searchStart; t < lines.length; t++) {
289
+ if (!pattern.top.some(re => re.test(lines[t])))
290
+ continue;
291
+ if (pattern.bottom.length === 0) {
292
+ const lastNonEmpty = findLastNonEmpty(lines, t + 1);
293
+ if (lastNonEmpty !== null && lastNonEmpty - t >= pattern.minGap) {
294
+ return true;
295
+ }
296
+ continue;
297
+ }
298
+ // Search for a matching bottom after this top
299
+ for (let b = t + 1; b < lines.length; b++) {
300
+ if (pattern.bottom.some(re => re.test(lines[b]))) {
301
+ if (b - t >= pattern.minGap) {
302
+ return true;
303
+ }
304
+ }
305
+ }
306
+ // No matching bottom for this top — try next top match
307
+ }
308
+ return false;
309
+ }
310
+ function extractPattern(lines, pattern) {
311
+ let topIdx = null;
312
+ let bottomIdx = null;
313
+ // Only search the last 30 lines to avoid matching scrollback text
314
+ const searchStart = Math.max(0, lines.length - 30);
315
+ for (let i = searchStart; i < lines.length; i++) {
316
+ if (topIdx === null) {
317
+ if (pattern.top.some(re => re.test(lines[i]))) {
318
+ topIdx = i;
319
+ }
320
+ }
321
+ else if (pattern.bottom.length > 0 && pattern.bottom.some(re => re.test(lines[i]))) {
322
+ bottomIdx = i;
323
+ break;
324
+ }
325
+ }
326
+ if (topIdx === null)
327
+ return null;
328
+ if (pattern.bottom.length === 0) {
329
+ bottomIdx = findLastNonEmpty(lines, topIdx + 1);
330
+ }
331
+ if (bottomIdx === null || bottomIdx - topIdx < pattern.minGap)
332
+ return null;
333
+ return lines.slice(topIdx, bottomIdx + 1).join('\n').trimEnd();
334
+ }
335
+ function findLastNonEmpty(lines, from = 0) {
336
+ let last = null;
337
+ for (let i = from; i < lines.length; i++) {
338
+ if (lines[i].trim())
339
+ last = i;
340
+ }
341
+ return last;
342
+ }
343
+ //# sourceMappingURL=terminal-parser.js.map