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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- 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
|