aegis-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
|
@@ -0,0 +1,346 @@
|
|
|
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 (old)
|
|
36
|
+
/^\s*Quick safety check/, // workspace trust (CC ≥2.1.92)
|
|
37
|
+
/^\s*Is this a project you created/, // workspace trust alt text
|
|
38
|
+
/^\s*Do you want to allow (reading|writing)/, // file scope
|
|
39
|
+
/^\s*Do you want to run this command/, // alt bash approval
|
|
40
|
+
/^\s*Do you want to allow writing to/, // file write scope
|
|
41
|
+
/^\s*Continue\?/, // continuation
|
|
42
|
+
],
|
|
43
|
+
bottom: [/^\s*Esc to cancel/],
|
|
44
|
+
minGap: 2,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'permission_prompt',
|
|
48
|
+
top: [/^\s*❯\s*1\.\s*Yes/],
|
|
49
|
+
bottom: [],
|
|
50
|
+
minGap: 2,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'bash_approval',
|
|
54
|
+
top: [
|
|
55
|
+
/^\s*Bash command\s*$/,
|
|
56
|
+
/^\s*This command requires approval/,
|
|
57
|
+
],
|
|
58
|
+
bottom: [/^\s*Esc to cancel/],
|
|
59
|
+
minGap: 2,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'settings',
|
|
63
|
+
top: [
|
|
64
|
+
/^\s*Settings:.*tab to cycle/,
|
|
65
|
+
/^\s*Select model/,
|
|
66
|
+
],
|
|
67
|
+
bottom: [
|
|
68
|
+
/^\s*Esc to cancel/,
|
|
69
|
+
/^\s*Esc to exit/,
|
|
70
|
+
/^\s*Enter to confirm/,
|
|
71
|
+
/^\s*Type to filter/,
|
|
72
|
+
],
|
|
73
|
+
minGap: 2,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'error',
|
|
77
|
+
top: [
|
|
78
|
+
/^Error:/,
|
|
79
|
+
/Rate limit/,
|
|
80
|
+
/Authentication failed/,
|
|
81
|
+
/overloaded/i,
|
|
82
|
+
/API error/,
|
|
83
|
+
/^429\b/,
|
|
84
|
+
],
|
|
85
|
+
bottom: [/^\s*❯\s*$/],
|
|
86
|
+
minGap: 1,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
// Spinner characters Claude Code uses (including braille spinners with TERM=xterm-256color)
|
|
90
|
+
// Issue #102: CC also uses * (asterisk) and ● (bullet) for status lines like "* Perambulating…"
|
|
91
|
+
const STATUS_SPINNERS = new Set([
|
|
92
|
+
'·', '✻', '✽', '✶', '✳', '✢', '*', '●',
|
|
93
|
+
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
|
|
94
|
+
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷',
|
|
95
|
+
]);
|
|
96
|
+
/** Detect the UI state from captured pane text. */
|
|
97
|
+
export function detectUIState(paneText) {
|
|
98
|
+
if (!paneText)
|
|
99
|
+
return 'unknown';
|
|
100
|
+
const lines = paneText.trim().split('\n');
|
|
101
|
+
// Check for interactive UI patterns first (highest priority)
|
|
102
|
+
for (const pattern of UI_PATTERNS) {
|
|
103
|
+
if (tryMatchPattern(lines, pattern)) {
|
|
104
|
+
return pattern.name;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Check for working status — scan entire pane for active spinners
|
|
108
|
+
const statusText = parseStatusLine(paneText);
|
|
109
|
+
const hasActiveSpinner = hasSpinnerAnywhere(lines);
|
|
110
|
+
// Check for the prompt (❯) near the bottom
|
|
111
|
+
const hasPrompt = hasIdlePrompt(lines);
|
|
112
|
+
const hasChrome = hasChromeSeparator(lines);
|
|
113
|
+
if (statusText) {
|
|
114
|
+
// "Worked for Xs" = finished, not working; "Aborted" = CC was interrupted
|
|
115
|
+
if (/^Worked for/i.test(statusText) || /^Compacted/i.test(statusText) || /aborted/i.test(statusText)) {
|
|
116
|
+
return hasPrompt ? 'idle' : 'unknown';
|
|
117
|
+
}
|
|
118
|
+
// Active spinner text = working regardless of prompt
|
|
119
|
+
return 'working';
|
|
120
|
+
}
|
|
121
|
+
// Even without parseStatusLine match, if we see active spinners → working
|
|
122
|
+
if (hasActiveSpinner) {
|
|
123
|
+
return 'working';
|
|
124
|
+
}
|
|
125
|
+
// L30: Check for compacting state — CC shows "Compacting..." when compacting context
|
|
126
|
+
// Checked after working so active spinners take priority over compacting text
|
|
127
|
+
const compactingState = detectCompacting(lines);
|
|
128
|
+
if (compactingState)
|
|
129
|
+
return 'compacting';
|
|
130
|
+
// L31: Check for context window warning — CC shows "Context window X% full"
|
|
131
|
+
const contextWarning = detectContextWarning(lines);
|
|
132
|
+
if (contextWarning)
|
|
133
|
+
return 'context_warning';
|
|
134
|
+
if (hasPrompt) {
|
|
135
|
+
// L32: Differentiate idle (chrome separator present) vs waiting_for_input (no chrome)
|
|
136
|
+
return hasChrome ? 'idle' : 'waiting_for_input';
|
|
137
|
+
}
|
|
138
|
+
// Check for chrome separator (─────) near bottom = CC is loaded
|
|
139
|
+
if (hasChrome) {
|
|
140
|
+
return 'idle';
|
|
141
|
+
}
|
|
142
|
+
// L32: Check for waiting-for-input patterns without the idle separator
|
|
143
|
+
if (detectWaitingForInput(lines))
|
|
144
|
+
return 'waiting_for_input';
|
|
145
|
+
return 'unknown';
|
|
146
|
+
}
|
|
147
|
+
/** Number of lines from the bottom of the pane to scan for active spinners. */
|
|
148
|
+
const SPINNER_SEARCH_LINES = 30;
|
|
149
|
+
/** Check if any line in the pane has an active spinner character followed by working text. */
|
|
150
|
+
function hasSpinnerAnywhere(lines) {
|
|
151
|
+
// Only check lines in the content area (not the very bottom few which are prompt/footer)
|
|
152
|
+
const searchEnd = Math.max(0, lines.length - 3);
|
|
153
|
+
for (let i = Math.max(0, lines.length - SPINNER_SEARCH_LINES); i < searchEnd; i++) {
|
|
154
|
+
const stripped = lines[i].trim();
|
|
155
|
+
if (!stripped)
|
|
156
|
+
continue;
|
|
157
|
+
// Check for spinner characters at start of line, followed by text containing "…" or "..."
|
|
158
|
+
const firstChar = stripped[0];
|
|
159
|
+
if (STATUS_SPINNERS.has(firstChar) && stripped.length > 1) {
|
|
160
|
+
// For `*` (also a markdown bullet), require `* ` + ellipsis/dots to avoid false positives
|
|
161
|
+
if (firstChar === '*') {
|
|
162
|
+
if (stripped[1] !== ' ' || !(stripped.includes('…') || stripped.includes('...')))
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
else if (!(stripped.includes('…') || stripped.includes('...') || /[^\s\u00a0]/.test(stripped.slice(1)))) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Exclude "Worked for" which is a completion indicator, and "Aborted" which means CC stopped
|
|
169
|
+
if (/^.Worked for/i.test(stripped) || /^.Compacted/i.test(stripped) || /aborted/i.test(stripped))
|
|
170
|
+
continue;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/** Check if the prompt ❯ is visible between chrome separators. */
|
|
177
|
+
function hasIdlePrompt(lines) {
|
|
178
|
+
// Look for ❯ on its own line near the bottom, between two ─── separators
|
|
179
|
+
for (let i = Math.max(0, lines.length - 8); i < lines.length; i++) {
|
|
180
|
+
const stripped = lines[i].trim();
|
|
181
|
+
if (stripped === '❯' || stripped === '❯\u00a0' || stripped.startsWith('❯ ') || stripped.startsWith('❯\u00a0')) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
/** Check if a chrome separator (─────) is present near the bottom of the pane. */
|
|
188
|
+
function hasChromeSeparator(lines) {
|
|
189
|
+
for (let i = Math.max(0, lines.length - 10); i < lines.length; i++) {
|
|
190
|
+
const stripped = lines[i].trim();
|
|
191
|
+
if (stripped.length >= 20 && /^─+$/.test(stripped)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
/** L30: Detect compacting state — CC shows "Compacting..." when compacting context. */
|
|
198
|
+
function detectCompacting(lines) {
|
|
199
|
+
// Check last 15 lines for compacting indicators
|
|
200
|
+
const searchStart = Math.max(0, lines.length - 15);
|
|
201
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
202
|
+
const line = lines[i].trim();
|
|
203
|
+
if (!line)
|
|
204
|
+
continue;
|
|
205
|
+
if (/compacting/i.test(line) && !/compacted/i.test(line)) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
/** L31: Detect context window warning — CC shows "Context window X% full". */
|
|
212
|
+
function detectContextWarning(lines) {
|
|
213
|
+
// Check last 15 lines for context window warnings
|
|
214
|
+
const searchStart = Math.max(0, lines.length - 15);
|
|
215
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
216
|
+
const line = lines[i].trim();
|
|
217
|
+
if (!line)
|
|
218
|
+
continue;
|
|
219
|
+
if (/context\s+window/i.test(line) && /(\d+)%/.test(line)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
/** L32: Detect waiting-for-input state — CC prompt without chrome separator. */
|
|
226
|
+
function detectWaitingForInput(lines) {
|
|
227
|
+
// Look for prompt-like text near the bottom without the chrome separator
|
|
228
|
+
const searchStart = Math.max(0, lines.length - 8);
|
|
229
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
230
|
+
const stripped = lines[i].trim();
|
|
231
|
+
// ❯ with text (but not bare ❯ which is idle)
|
|
232
|
+
if ((stripped.startsWith('❯ ') || stripped.startsWith('❯\u00a0')) && stripped.length > 2) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
// CC asking questions like "What would you like to do?" near bottom
|
|
236
|
+
if (/^(What would you like|What do you want|How would you like|How should I)/i.test(stripped)) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
/** Extract the interactive UI content if present. */
|
|
243
|
+
export function extractInteractiveContent(paneText) {
|
|
244
|
+
if (!paneText)
|
|
245
|
+
return null;
|
|
246
|
+
const lines = paneText.trim().split('\n');
|
|
247
|
+
for (const pattern of UI_PATTERNS) {
|
|
248
|
+
const result = extractPattern(lines, pattern);
|
|
249
|
+
if (result)
|
|
250
|
+
return { content: result, name: pattern.name };
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
/** Parse the status line text (what CC is doing). */
|
|
255
|
+
export function parseStatusLine(paneText) {
|
|
256
|
+
if (!paneText)
|
|
257
|
+
return null;
|
|
258
|
+
const lines = paneText.split('\n');
|
|
259
|
+
// Find chrome separator
|
|
260
|
+
let chromeIdx = null;
|
|
261
|
+
const searchStart = Math.max(0, lines.length - 10);
|
|
262
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
263
|
+
const stripped = lines[i].trim();
|
|
264
|
+
if (stripped.length >= 20 && /^─+$/.test(stripped)) {
|
|
265
|
+
chromeIdx = i;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (chromeIdx === null)
|
|
270
|
+
return null;
|
|
271
|
+
// Check lines above separator for spinner
|
|
272
|
+
for (let i = chromeIdx - 1; i > Math.max(chromeIdx - 10, -1); i--) {
|
|
273
|
+
const line = lines[i].trim();
|
|
274
|
+
if (!line)
|
|
275
|
+
continue;
|
|
276
|
+
if (STATUS_SPINNERS.has(line[0])) {
|
|
277
|
+
// For `*`, require `* ` + ellipsis/dots to avoid matching markdown bullets
|
|
278
|
+
if (line[0] === '*' && (line[1] !== ' ' || !(line.includes('…') || line.includes('...')))) {
|
|
279
|
+
// Not a real spinner line — skip
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
return line.slice(1).trim();
|
|
283
|
+
}
|
|
284
|
+
// Skip non-spinner lines (tool output between spinner and separator) and keep scanning
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
function tryMatchPattern(lines, pattern) {
|
|
289
|
+
// Only search the last 30 lines to avoid matching scrollback text
|
|
290
|
+
const searchStart = Math.max(0, lines.length - 30);
|
|
291
|
+
// Try each top match — don't give up after the first one fails to find a bottom
|
|
292
|
+
for (let t = searchStart; t < lines.length; t++) {
|
|
293
|
+
if (!pattern.top.some(re => re.test(lines[t])))
|
|
294
|
+
continue;
|
|
295
|
+
if (pattern.bottom.length === 0) {
|
|
296
|
+
const lastNonEmpty = findLastNonEmpty(lines, t + 1);
|
|
297
|
+
if (lastNonEmpty !== null && lastNonEmpty - t >= pattern.minGap) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Search for a matching bottom after this top
|
|
303
|
+
for (let b = t + 1; b < lines.length; b++) {
|
|
304
|
+
if (pattern.bottom.some(re => re.test(lines[b]))) {
|
|
305
|
+
if (b - t >= pattern.minGap) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// No matching bottom for this top — try next top match
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
function extractPattern(lines, pattern) {
|
|
315
|
+
let topIdx = null;
|
|
316
|
+
let bottomIdx = null;
|
|
317
|
+
// Only search the last 30 lines to avoid matching scrollback text
|
|
318
|
+
const searchStart = Math.max(0, lines.length - 30);
|
|
319
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
320
|
+
if (topIdx === null) {
|
|
321
|
+
if (pattern.top.some(re => re.test(lines[i]))) {
|
|
322
|
+
topIdx = i;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else if (pattern.bottom.length > 0 && pattern.bottom.some(re => re.test(lines[i]))) {
|
|
326
|
+
bottomIdx = i;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (topIdx === null)
|
|
331
|
+
return null;
|
|
332
|
+
if (pattern.bottom.length === 0) {
|
|
333
|
+
bottomIdx = findLastNonEmpty(lines, topIdx + 1);
|
|
334
|
+
}
|
|
335
|
+
if (bottomIdx === null || bottomIdx - topIdx < pattern.minGap)
|
|
336
|
+
return null;
|
|
337
|
+
return lines.slice(topIdx, bottomIdx + 1).join('\n').trimEnd();
|
|
338
|
+
}
|
|
339
|
+
function findLastNonEmpty(lines, from = 0) {
|
|
340
|
+
let last = null;
|
|
341
|
+
for (let i = from; i < lines.length; i++) {
|
|
342
|
+
if (lines[i].trim())
|
|
343
|
+
last = i;
|
|
344
|
+
}
|
|
345
|
+
return last;
|
|
346
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-capture-cache.ts — TTL-based cache for capture-pane results.
|
|
3
|
+
*
|
|
4
|
+
* Avoids redundant tmux capture-pane CLI calls when the same window
|
|
5
|
+
* is polled multiple times within a short window (e.g. monitor poll +
|
|
6
|
+
* status check hitting the same pane).
|
|
7
|
+
*/
|
|
8
|
+
export declare class TmuxCaptureCache {
|
|
9
|
+
private cache;
|
|
10
|
+
private readonly ttlMs;
|
|
11
|
+
constructor(ttlMs?: number);
|
|
12
|
+
/** Return cached capture-pane text if within TTL, otherwise call `captureFn` and cache. */
|
|
13
|
+
get(windowId: string, captureFn: () => Promise<string>): Promise<string>;
|
|
14
|
+
/** Invalidate a single window's cached result. */
|
|
15
|
+
invalidate(windowId: string): void;
|
|
16
|
+
/** Clear all cached entries. */
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-capture-cache.ts — TTL-based cache for capture-pane results.
|
|
3
|
+
*
|
|
4
|
+
* Avoids redundant tmux capture-pane CLI calls when the same window
|
|
5
|
+
* is polled multiple times within a short window (e.g. monitor poll +
|
|
6
|
+
* status check hitting the same pane).
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_TTL_MS = 500;
|
|
9
|
+
export class TmuxCaptureCache {
|
|
10
|
+
cache = new Map();
|
|
11
|
+
ttlMs;
|
|
12
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
13
|
+
this.ttlMs = ttlMs;
|
|
14
|
+
}
|
|
15
|
+
/** Return cached capture-pane text if within TTL, otherwise call `captureFn` and cache. */
|
|
16
|
+
async get(windowId, captureFn) {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const entry = this.cache.get(windowId);
|
|
19
|
+
if (entry && now - entry.at < this.ttlMs) {
|
|
20
|
+
return entry.text;
|
|
21
|
+
}
|
|
22
|
+
const text = await captureFn();
|
|
23
|
+
this.cache.set(windowId, { text, at: now });
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
/** Invalidate a single window's cached result. */
|
|
27
|
+
invalidate(windowId) {
|
|
28
|
+
this.cache.delete(windowId);
|
|
29
|
+
}
|
|
30
|
+
/** Clear all cached entries. */
|
|
31
|
+
clear() {
|
|
32
|
+
this.cache.clear();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/tmux.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux.ts — Low-level tmux interaction layer.
|
|
3
|
+
*
|
|
4
|
+
* Wraps tmux CLI commands to manage windows inside a named session.
|
|
5
|
+
* Port of CCBot's tmux_manager.py to TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
/** Build the platform-specific launch wrapper that clears inherited tmux vars. */
|
|
8
|
+
export declare function buildClaudeLaunchCommand(baseCommand: string, platform?: NodeJS.Platform): string;
|
|
9
|
+
/** Thrown when a tmux command exceeds its timeout. */
|
|
10
|
+
export declare class TmuxTimeoutError extends Error {
|
|
11
|
+
constructor(args: string[], timeoutMs: number);
|
|
12
|
+
}
|
|
13
|
+
export interface TmuxWindow {
|
|
14
|
+
windowId: string;
|
|
15
|
+
windowName: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
paneCommand: string;
|
|
18
|
+
paneDead?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare class TmuxManager {
|
|
21
|
+
private sessionName;
|
|
22
|
+
/** tmux socket name (-L flag). Isolates sessions from other tmux instances. */
|
|
23
|
+
readonly socketName: string;
|
|
24
|
+
/** #357: Cache for window existence checks — avoids repeated tmux CLI calls. */
|
|
25
|
+
private windowExistsCache;
|
|
26
|
+
private static readonly WINDOW_CACHE_TTL_MS;
|
|
27
|
+
constructor(sessionName?: string, socketName?: string);
|
|
28
|
+
/** Promise-chain queue that serializes all tmux CLI calls to prevent race conditions. */
|
|
29
|
+
private queue;
|
|
30
|
+
/** #403: Counter of in-flight createWindow calls — direct methods must queue when > 0. */
|
|
31
|
+
private _creatingCount;
|
|
32
|
+
/** #357: Short-lived cache for window existence checks to reduce CLI calls. */
|
|
33
|
+
private windowCache;
|
|
34
|
+
/** Run `fn` sequentially after all previously-queued operations complete. */
|
|
35
|
+
private serialize;
|
|
36
|
+
/** Run a tmux command and return stdout (serialized through the queue).
|
|
37
|
+
* Issue #66: All tmux commands have a timeout to prevent hangs.
|
|
38
|
+
* A single hung tmux command would otherwise block the entire Aegis server.
|
|
39
|
+
*/
|
|
40
|
+
private tmux;
|
|
41
|
+
private tmuxInternal;
|
|
42
|
+
/** Determine whether an error indicates tmux rejected a duplicate window name. */
|
|
43
|
+
private isDuplicateWindowNameError;
|
|
44
|
+
/** Compute an available window name by suffixing -2, -3, ... when needed. */
|
|
45
|
+
private resolveAvailableWindowName;
|
|
46
|
+
/** Ensure our tmux session exists and is healthy.
|
|
47
|
+
* Issue #7: After prolonged uptime, tmux session may exist but be degraded.
|
|
48
|
+
* We verify by listing windows — if that fails, recreate the session.
|
|
49
|
+
*/
|
|
50
|
+
ensureSession(): Promise<void>;
|
|
51
|
+
/** #403: Internal version that calls tmuxInternal directly (safe inside serialize). */
|
|
52
|
+
private ensureSessionInternal;
|
|
53
|
+
/** List all windows (excluding the placeholder _bridge_main). */
|
|
54
|
+
listWindows(): Promise<TmuxWindow[]>;
|
|
55
|
+
/** Create a new window, start claude, return window info.
|
|
56
|
+
* Issue #7: Retries up to 3x on failure, with tmux session health check between retries.
|
|
57
|
+
*/
|
|
58
|
+
createWindow(opts: {
|
|
59
|
+
workDir: string;
|
|
60
|
+
windowName: string;
|
|
61
|
+
claudeCommand?: string;
|
|
62
|
+
resumeSessionId?: string;
|
|
63
|
+
env?: Record<string, string>;
|
|
64
|
+
permissionMode?: string;
|
|
65
|
+
/** Path to a CC settings JSON file (via --settings flag). */
|
|
66
|
+
settingsFile?: string;
|
|
67
|
+
/** @deprecated Use permissionMode instead. Maps true→bypassPermissions, false→default. */
|
|
68
|
+
autoApprove?: boolean;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
windowId: string;
|
|
71
|
+
windowName: string;
|
|
72
|
+
freshSessionId?: string;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Archive old Claude session files so interactive mode starts fresh.
|
|
76
|
+
*
|
|
77
|
+
* Claude CLI computes a project hash from the workDir path:
|
|
78
|
+
* /home/user/projects/foo → -home-user-projects-foo
|
|
79
|
+
* and stores sessions at ~/.claude/projects/<hash>/*.jsonl.
|
|
80
|
+
*
|
|
81
|
+
* In interactive mode, Claude always auto-resumes the latest .jsonl file.
|
|
82
|
+
* There is no CLI flag to disable this. The only reliable way to force a
|
|
83
|
+
* fresh session is to move existing .jsonl files out of the way.
|
|
84
|
+
*
|
|
85
|
+
* Files are moved to an `_archived/` subfolder (not deleted), so they can
|
|
86
|
+
* be recovered if needed.
|
|
87
|
+
*/
|
|
88
|
+
private archiveStaleSessionFiles;
|
|
89
|
+
/** Issue #23: Set env vars securely without exposing values in tmux pane.
|
|
90
|
+
* Writes vars to a temp file, sources it, then deletes it.
|
|
91
|
+
* Values never appear in terminal scrollback or capture-pane output.
|
|
92
|
+
*/
|
|
93
|
+
private setEnvSecure;
|
|
94
|
+
/** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
|
|
95
|
+
private setEnvSecureWin32;
|
|
96
|
+
/** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
|
|
97
|
+
* sendKeys, safe to call from inside a serialize() callback without deadlocking.
|
|
98
|
+
* Identical logic otherwise. */
|
|
99
|
+
private setEnvSecureDirect;
|
|
100
|
+
/** #909: Direct Windows variant that avoids serialize() re-entry. */
|
|
101
|
+
private setEnvSecureDirectWin32;
|
|
102
|
+
/** P1 fix: Check if a window exists. Returns true if window is in the session.
|
|
103
|
+
* #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
|
|
104
|
+
windowExists(windowId: string): Promise<boolean>;
|
|
105
|
+
/** Issue #69: Get the PID of the first pane in a window. Returns null on error. */
|
|
106
|
+
listPanePid(windowId: string): Promise<number | null>;
|
|
107
|
+
/** Issue #69: Check if a PID is alive using kill -0. */
|
|
108
|
+
isPidAlive(pid: number): boolean;
|
|
109
|
+
/** Get detailed window info for health checks.
|
|
110
|
+
* Issue #2: Returns window existence, pane command, and whether Claude is running.
|
|
111
|
+
*/
|
|
112
|
+
getWindowHealth(windowId: string): Promise<{
|
|
113
|
+
windowExists: boolean;
|
|
114
|
+
paneCommand: string | null;
|
|
115
|
+
claudeRunning: boolean;
|
|
116
|
+
paneDead: boolean;
|
|
117
|
+
}>;
|
|
118
|
+
/** Send text to a window's active pane. */
|
|
119
|
+
sendKeys(windowId: string, text: string, enter?: boolean): Promise<void>;
|
|
120
|
+
/** Check if a pane state indicates CC has received input (non-idle). */
|
|
121
|
+
private isActiveState;
|
|
122
|
+
/** Verify that a message was delivered to Claude Code.
|
|
123
|
+
* Issue #1 v2: Compares pre-send and post-send pane state to detect delivery.
|
|
124
|
+
*
|
|
125
|
+
* Strategy:
|
|
126
|
+
* 1. If CC transitioned from idle → active state → confirmed
|
|
127
|
+
* 2. If CC is in any active state (working, permission, etc.) → confirmed
|
|
128
|
+
* 3. If sent text (prefix) is visible in the pane → confirmed
|
|
129
|
+
* 4. If CC is still idle with no trace of input → NOT confirmed
|
|
130
|
+
* 5. Unknown state → benefit of the doubt (confirmed)
|
|
131
|
+
*
|
|
132
|
+
* The `preSendState` parameter enables state-change detection to avoid
|
|
133
|
+
* false negatives during transitional moments.
|
|
134
|
+
*/
|
|
135
|
+
verifyDelivery(windowId: string, sentText: string, preSendState?: string): Promise<boolean>;
|
|
136
|
+
/** Send text and verify delivery with retry.
|
|
137
|
+
* Issue #1 v2: Captures pre-send state and only re-sends if pane is still idle.
|
|
138
|
+
* Prevents duplicate prompt delivery that plagued v1.
|
|
139
|
+
*/
|
|
140
|
+
sendKeysVerified(windowId: string, text: string, maxAttempts?: number): Promise<{
|
|
141
|
+
delivered: boolean;
|
|
142
|
+
attempts: number;
|
|
143
|
+
}>;
|
|
144
|
+
/** Send a special key (Escape, C-c, etc.) */
|
|
145
|
+
sendSpecialKey(windowId: string, key: string): Promise<void>;
|
|
146
|
+
/** Capture the visible pane content.
|
|
147
|
+
* Issue #89 L23: Strips DCS passthrough sequences (ESC P ... ESC \\)
|
|
148
|
+
* that can leak through tmux's capture-pane into the output.
|
|
149
|
+
*/
|
|
150
|
+
capturePane(windowId: string): Promise<string>;
|
|
151
|
+
/** Capture pane content through the serialize queue.
|
|
152
|
+
* #824: Always serialize to prevent race conditions with concurrent reads
|
|
153
|
+
* from monitor polls and ! command mode. The previous _creatingCount guard
|
|
154
|
+
* only queued during window creation, leaving a race window at other times.
|
|
155
|
+
*/
|
|
156
|
+
capturePaneDirect(windowId: string): Promise<string>;
|
|
157
|
+
private capturePaneDirectInternal;
|
|
158
|
+
/** Send keys WITHOUT going through the serialize queue.
|
|
159
|
+
* Used for critical-path operations (e.g., sendInitialPrompt).
|
|
160
|
+
* Simplified version: sends literal text + Enter (no ! command mode handling).
|
|
161
|
+
* #403: During window creation (_creatingCount > 0), queues behind serialize
|
|
162
|
+
* to avoid racing with the creation sequence.
|
|
163
|
+
*/
|
|
164
|
+
sendKeysDirect(windowId: string, text: string, enter?: boolean): Promise<void>;
|
|
165
|
+
private sendKeysDirectInternal;
|
|
166
|
+
/** Resize a window's pane to the given dimensions. */
|
|
167
|
+
resizePane(windowId: string, cols: number, rows: number): Promise<void>;
|
|
168
|
+
/** Kill a window. */
|
|
169
|
+
killWindow(windowId: string): Promise<void>;
|
|
170
|
+
/** Issue #397: Check if the tmux server is reachable and healthy.
|
|
171
|
+
* Returns { healthy, error } — does not throw. */
|
|
172
|
+
isServerHealthy(): Promise<{
|
|
173
|
+
healthy: boolean;
|
|
174
|
+
error: string | null;
|
|
175
|
+
}>;
|
|
176
|
+
/** Issue #397: Check if a tmux error indicates the server crashed (vs window-not-found).
|
|
177
|
+
* Server crash errors contain specific patterns from tmux CLI. */
|
|
178
|
+
isTmuxServerError(error: unknown): boolean;
|
|
179
|
+
/** Kill the entire tmux session. Used for cleanup on shutdown. */
|
|
180
|
+
killSession(sessionName?: string): Promise<void>;
|
|
181
|
+
/** #357: Poll until condition returns true or timeout elapses. */
|
|
182
|
+
private pollUntil;
|
|
183
|
+
}
|