ninja-terminals 2.0.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.
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Maximum "rest" duration (ms) for recency scoring.
5
+ * Terminals resting longer than this get max recency score.
6
+ */
7
+ const MAX_REST_MS = 10 * 60 * 1000; // 10 minutes
8
+
9
+ /**
10
+ * Context usage threshold. Terminals above this are excluded.
11
+ */
12
+ const CONTEXT_THRESHOLD = 80;
13
+
14
+ /**
15
+ * Scoring weights (must sum to 1.0).
16
+ */
17
+ const WEIGHT_AFFINITY = 0.4;
18
+ const WEIGHT_CAPACITY = 0.4;
19
+ const WEIGHT_RECENCY = 0.2;
20
+
21
+ /**
22
+ * Check whether two scope arrays have overlapping paths.
23
+ * A path overlaps if one is a prefix of the other (directory containment)
24
+ * or if they are identical.
25
+ *
26
+ * @param {string[]} scopeA
27
+ * @param {string[]} scopeB
28
+ * @returns {boolean}
29
+ */
30
+ function scopesOverlap(scopeA, scopeB) {
31
+ for (const a of scopeA) {
32
+ for (const b of scopeB) {
33
+ if (a === b || a.startsWith(b) || b.startsWith(a)) {
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Compute affinity score: how many of the task's scope paths overlap with
43
+ * the terminal's previousFiles. Returns 0-100.
44
+ *
45
+ * @param {string[]} taskScope - File/directory paths the task owns
46
+ * @param {string[]} previousFiles - Files the terminal has worked on
47
+ * @returns {number} 0-100
48
+ */
49
+ function computeAffinity(taskScope, previousFiles) {
50
+ if (!taskScope || taskScope.length === 0) return 50; // neutral if no scope
51
+ if (!previousFiles || previousFiles.length === 0) return 0;
52
+
53
+ let matches = 0;
54
+ for (const scopePath of taskScope) {
55
+ for (const file of previousFiles) {
56
+ if (file === scopePath || file.startsWith(scopePath) || scopePath.startsWith(file)) {
57
+ matches++;
58
+ break; // count each scope path at most once
59
+ }
60
+ }
61
+ }
62
+
63
+ return Math.round((matches / taskScope.length) * 100);
64
+ }
65
+
66
+ /**
67
+ * Compute capacity score from context percentage. More headroom = higher score.
68
+ *
69
+ * @param {number} contextPct - Current context window usage (0-100)
70
+ * @returns {number} 0-100
71
+ */
72
+ function computeCapacity(contextPct) {
73
+ return Math.max(0, Math.min(100, 100 - contextPct));
74
+ }
75
+
76
+ /**
77
+ * Compute recency score: how long the terminal has been resting.
78
+ * Longer rest = higher score, capped at MAX_REST_MS.
79
+ *
80
+ * @param {number|null} lastTaskCompletedAt - Timestamp of last task completion
81
+ * @returns {number} 0-100
82
+ */
83
+ function computeRecency(lastTaskCompletedAt) {
84
+ if (!lastTaskCompletedAt) return 100; // never used = fully rested
85
+ const elapsed = Date.now() - lastTaskCompletedAt;
86
+ if (elapsed <= 0) return 0;
87
+ return Math.round(Math.min(elapsed / MAX_REST_MS, 1) * 100);
88
+ }
89
+
90
+ /**
91
+ * Filter terminals that cannot accept a given task.
92
+ *
93
+ * Exclusion criteria:
94
+ * - Status is not 'idle'
95
+ * - contextPct exceeds threshold (>80)
96
+ * - Circuit breaker is OPEN
97
+ * - Scope conflicts with task scope
98
+ *
99
+ * @param {object[]} terminals - Array of terminal status objects
100
+ * @param {object} task - Task object with scope property
101
+ * @returns {object[]} Filtered array of eligible terminals
102
+ * @throws {Error} If inputs are invalid
103
+ */
104
+ function filterTerminals(terminals, task) {
105
+ if (!Array.isArray(terminals)) {
106
+ throw new Error('terminals must be an array');
107
+ }
108
+ if (!task || typeof task !== 'object') {
109
+ throw new Error('task must be a non-null object');
110
+ }
111
+
112
+ const taskScope = Array.isArray(task.scope) ? task.scope : [];
113
+
114
+ return terminals.filter((terminal) => {
115
+ // Must be idle
116
+ if (terminal.status !== 'idle') return false;
117
+
118
+ // Must have context headroom
119
+ if (typeof terminal.contextPct === 'number' && terminal.contextPct > CONTEXT_THRESHOLD) {
120
+ return false;
121
+ }
122
+
123
+ // Circuit breaker must not be open
124
+ if (terminal.circuitBreakerState === 'OPEN') return false;
125
+
126
+ // Scope must not conflict (only if both have scope)
127
+ if (
128
+ taskScope.length > 0 &&
129
+ Array.isArray(terminal.scope) &&
130
+ terminal.scope.length > 0 &&
131
+ scopesOverlap(taskScope, terminal.scope)
132
+ ) {
133
+ return false;
134
+ }
135
+
136
+ return true;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Score eligible terminal candidates for a task using a weighted multi-factor model.
142
+ *
143
+ * Factors and weights:
144
+ * - Affinity (40%): overlap between task scope and terminal's previous files
145
+ * - Capacity (40%): available context window headroom
146
+ * - Recency (20%): time since last task completion (longer rest = better)
147
+ *
148
+ * @param {object[]} candidates - Filtered array of terminal status objects
149
+ * @param {object} task - Task object with scope property
150
+ * @returns {object[]} Candidates with `.score` property, sorted descending by score
151
+ * @throws {Error} If inputs are invalid
152
+ */
153
+ function scoreTerminals(candidates, task) {
154
+ if (!Array.isArray(candidates)) {
155
+ throw new Error('candidates must be an array');
156
+ }
157
+ if (!task || typeof task !== 'object') {
158
+ throw new Error('task must be a non-null object');
159
+ }
160
+
161
+ const taskScope = Array.isArray(task.scope) ? task.scope : [];
162
+
163
+ const scored = candidates.map((terminal) => {
164
+ const affinity = computeAffinity(
165
+ taskScope,
166
+ Array.isArray(terminal.previousFiles) ? terminal.previousFiles : []
167
+ );
168
+ const capacity = computeCapacity(
169
+ typeof terminal.contextPct === 'number' ? terminal.contextPct : 0
170
+ );
171
+ const recency = computeRecency(terminal.lastTaskCompletedAt || null);
172
+
173
+ const score = Math.round(
174
+ affinity * WEIGHT_AFFINITY +
175
+ capacity * WEIGHT_CAPACITY +
176
+ recency * WEIGHT_RECENCY
177
+ );
178
+
179
+ return { ...terminal, score };
180
+ });
181
+
182
+ scored.sort((a, b) => b.score - a.score);
183
+ return scored;
184
+ }
185
+
186
+ /**
187
+ * Convenience function: filter, score, and select the best terminal for a task.
188
+ *
189
+ * @param {object[]} terminals - Array of terminal status objects
190
+ * @param {object} task - Task object
191
+ * @returns {object|null} Best terminal with score, or null if none eligible
192
+ */
193
+ function selectTerminal(terminals, task) {
194
+ if (!Array.isArray(terminals)) {
195
+ throw new Error('terminals must be an array');
196
+ }
197
+ if (!task || typeof task !== 'object') {
198
+ throw new Error('task must be a non-null object');
199
+ }
200
+
201
+ const filtered = filterTerminals(terminals, task);
202
+ if (filtered.length === 0) return null;
203
+
204
+ const scored = scoreTerminals(filtered, task);
205
+ return scored.length > 0 ? scored[0] : null;
206
+ }
207
+
208
+ module.exports = {
209
+ filterTerminals,
210
+ scoreTerminals,
211
+ selectTerminal,
212
+ };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Worker settings generator
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Generate a Claude Code worker settings object for a terminal.
12
+ *
13
+ * @param {number|string} terminalId - Terminal identifier
14
+ * @param {string|string[]} scope - File scope path(s), or '*'/'' for unrestricted
15
+ * @param {Object} [options={}]
16
+ * @param {number} [options.port=3000] - Server port for hook URLs
17
+ * @param {string[]} [options.additionalAllow=[]] - Extra allow rules to merge
18
+ * @param {string[]} [options.additionalDeny=[]] - Extra deny rules to merge
19
+ * @returns {Object} Settings object suitable for `.claude/settings.local.json`
20
+ */
21
+ function generateWorkerSettings(terminalId, scope, options = {}) {
22
+ const port = options.port || 3000;
23
+ const additionalAllow = options.additionalAllow || [];
24
+ const additionalDeny = options.additionalDeny || [];
25
+
26
+ // Build Edit/Write rules based on scope
27
+ const editWriteRules = [];
28
+ const unrestricted = !scope || scope === '*' || (Array.isArray(scope) && scope.length === 0);
29
+
30
+ if (unrestricted) {
31
+ editWriteRules.push('Edit', 'Write');
32
+ } else {
33
+ const scopes = Array.isArray(scope) ? scope : [scope];
34
+ for (const s of scopes) {
35
+ // Normalize: ensure trailing /** for glob matching
36
+ const normalized = s.endsWith('/**') ? s : (s.endsWith('/') ? `${s}**` : `${s}/**`);
37
+ const scopePath = normalized.startsWith('/') ? normalized : `/${normalized}`;
38
+ editWriteRules.push(`Edit(${scopePath})`, `Write(${scopePath})`);
39
+ }
40
+ }
41
+
42
+ const allow = [
43
+ 'Read',
44
+ 'Glob',
45
+ 'Grep',
46
+ ...editWriteRules,
47
+ // Safe bash commands
48
+ 'Bash(npm test *)',
49
+ 'Bash(npm run *)',
50
+ 'Bash(node *)',
51
+ 'Bash(npx *)',
52
+ 'Bash(git diff *)',
53
+ 'Bash(git log *)',
54
+ 'Bash(git status)',
55
+ 'Bash(ls *)',
56
+ 'Bash(cat *)',
57
+ 'Bash(wc *)',
58
+ 'Bash(head *)',
59
+ 'Bash(tail *)',
60
+ 'Bash(mkdir *)',
61
+ 'Bash(cp *)',
62
+ // MCP tools — all enabled servers
63
+ 'mcp__studychat__*',
64
+ 'mcp__postforme__*',
65
+ 'mcp__render-billing__*',
66
+ 'mcp__netlify-billing__*',
67
+ 'mcp__chrome-devtools__*',
68
+ 'mcp__gkchatty-production__*',
69
+ 'mcp__builder-pro-mcp__*',
70
+ 'mcp__gmail__*',
71
+ 'mcp__c2c__*',
72
+ 'mcp__atlas-architect__*',
73
+ // Network and research
74
+ 'WebFetch(*)',
75
+ 'WebSearch(*)',
76
+ // Additional bash
77
+ 'Bash(curl *)',
78
+ 'Bash(cd *)',
79
+ 'Bash(grep *)',
80
+ 'Bash(find *)',
81
+ 'Bash(echo *)',
82
+ 'Bash(sleep *)',
83
+ 'Bash(kill *)',
84
+ 'Bash(lsof *)',
85
+ 'Bash(ps *)',
86
+ 'Bash(git add *)',
87
+ 'Bash(git commit *)',
88
+ 'Bash(git push *)',
89
+ // Sub-agents
90
+ 'Agent(*)',
91
+ ...additionalAllow,
92
+ ];
93
+
94
+ const deny = [
95
+ 'Bash(rm -rf *)',
96
+ 'Bash(git push --force *)',
97
+ 'Bash(sudo *)',
98
+ 'Bash(chmod *)',
99
+ 'Bash(chown *)',
100
+ 'Read(./.env)',
101
+ 'Read(./.env.*)',
102
+ 'Read(~/.ssh/**)',
103
+ 'Read(~/.aws/**)',
104
+ 'Edit(./.env)',
105
+ 'Edit(./.env.*)',
106
+ ...additionalDeny,
107
+ ];
108
+
109
+ return {
110
+ permissions: { allow, deny },
111
+ sandbox: {
112
+ enabled: false,
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Generate and write worker settings to disk.
119
+ *
120
+ * @param {number|string} terminalId - Terminal identifier
121
+ * @param {string} projectDir - Absolute path to the project directory
122
+ * @param {string|string[]} scope - File scope path(s)
123
+ * @param {Object} [options={}]
124
+ * @returns {string} Absolute path to the written settings file
125
+ */
126
+ function writeWorkerSettings(terminalId, projectDir, scope, options = {}) {
127
+ const settings = generateWorkerSettings(terminalId, scope, options);
128
+ const claudeDir = path.join(projectDir, '.claude');
129
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
130
+
131
+ // Create .claude/ directory if it doesn't exist
132
+ if (!fs.existsSync(claudeDir)) {
133
+ fs.mkdirSync(claudeDir, { recursive: true });
134
+ }
135
+
136
+ // Merge with existing settings instead of overwriting
137
+ let existing = {};
138
+ try {
139
+ if (fs.existsSync(settingsPath)) {
140
+ existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
141
+ }
142
+ } catch { /* ignore parse errors, start fresh */ }
143
+
144
+ // Merge permissions: deduplicate allow/deny lists
145
+ const mergedAllow = [...new Set([...(existing.permissions?.allow || []), ...settings.permissions.allow])];
146
+ const mergedDeny = [...new Set([...(existing.permissions?.deny || []), ...settings.permissions.deny])];
147
+
148
+ const merged = {
149
+ ...existing,
150
+ permissions: { allow: mergedAllow, deny: mergedDeny },
151
+ sandbox: settings.sandbox,
152
+ // Preserve existing hooks, enabledMcpjsonServers, etc.
153
+ };
154
+
155
+ fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
156
+ return settingsPath;
157
+ }
158
+
159
+ module.exports = { generateWorkerSettings, writeWorkerSettings };
package/lib/sse.js ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Server-Sent Events manager.
5
+ * Maintains a set of connected Express response objects and broadcasts
6
+ * named events to all of them.
7
+ */
8
+ class SSEManager {
9
+ constructor() {
10
+ /** @type {Set<import('http').ServerResponse>} */
11
+ this._clients = new Set();
12
+ /** @type {NodeJS.Timeout|null} */
13
+ this._heartbeatTimer = null;
14
+ }
15
+
16
+ /**
17
+ * Register an Express response as an SSE client.
18
+ * Sets the required headers, sends an initial keepalive comment,
19
+ * and automatically removes the client on connection close.
20
+ *
21
+ * @param {import('http').ServerResponse} res - Express response object
22
+ */
23
+ addClient(res) {
24
+ res.writeHead(200, {
25
+ 'Content-Type': 'text/event-stream',
26
+ 'Cache-Control': 'no-cache',
27
+ 'Connection': 'keep-alive',
28
+ 'X-Accel-Buffering': 'no', // disable nginx buffering if proxied
29
+ });
30
+ res.write(':ok\n\n');
31
+
32
+ this._clients.add(res);
33
+
34
+ res.on('close', () => {
35
+ this._clients.delete(res);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Explicitly remove an SSE client.
41
+ * @param {import('http').ServerResponse} res
42
+ */
43
+ removeClient(res) {
44
+ this._clients.delete(res);
45
+ }
46
+
47
+ /**
48
+ * Broadcast a named event with JSON data to every connected client.
49
+ * Silently drops clients whose connections have ended.
50
+ *
51
+ * @param {string} eventName - SSE event name
52
+ * @param {*} data - Payload (will be JSON-stringified)
53
+ */
54
+ broadcast(eventName, data) {
55
+ const frame = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
56
+ for (const client of this._clients) {
57
+ if (client.writableEnded) {
58
+ this._clients.delete(client);
59
+ continue;
60
+ }
61
+ client.write(frame);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Start sending periodic heartbeat comments to keep connections alive.
67
+ * @param {number} [intervalMs=15000] - Heartbeat interval in milliseconds
68
+ */
69
+ startHeartbeat(intervalMs = 15000) {
70
+ this.stopHeartbeat();
71
+ this._heartbeatTimer = setInterval(() => {
72
+ for (const client of this._clients) {
73
+ if (client.writableEnded) {
74
+ this._clients.delete(client);
75
+ continue;
76
+ }
77
+ client.write(': heartbeat\n\n');
78
+ }
79
+ }, intervalMs);
80
+ // Allow the process to exit even if the timer is running
81
+ if (this._heartbeatTimer.unref) {
82
+ this._heartbeatTimer.unref();
83
+ }
84
+ }
85
+
86
+ /** Stop the heartbeat timer. */
87
+ stopHeartbeat() {
88
+ if (this._heartbeatTimer) {
89
+ clearInterval(this._heartbeatTimer);
90
+ this._heartbeatTimer = null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Number of currently connected SSE clients.
96
+ * @returns {number}
97
+ */
98
+ get clientCount() {
99
+ return this._clients.size;
100
+ }
101
+ }
102
+
103
+ module.exports = { SSEManager };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // ANSI stripping
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Strip all ANSI escape sequences and carriage returns from a string.
9
+ * @param {string} str
10
+ * @returns {string}
11
+ */
12
+ function stripAnsi(str) {
13
+ return str
14
+ .replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
15
+ .replace(/\x1b\[[?>=!]?[0-9;]*[a-zA-Z]/g, '') // CSI sequences
16
+ .replace(/\x1b[()][0-9A-Z]/g, '') // character set selection
17
+ .replace(/\x1b[>=<]/g, '') // keypad mode
18
+ .replace(/\x1b\[>[0-9;]*[a-zA-Z]/g, '') // private CSI
19
+ .replace(/\r/g, '')
20
+ .trim();
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Status detection
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Regex that matches status-bar noise lines that should be excluded. */
28
+ const STATUS_BAR_NOISE = /^(●|·|\/effort|\/mcp|high|low|medium|Failed to install|MCP server|Will retry|─+$|\?forshortcuts|forshortcuts)/i;
29
+
30
+ /** Regex that matches tool invocation patterns. */
31
+ const TOOL_RE = /Bash\(|Read\(|Edit\(|Write\(|Grep\(|Glob\(|Agent\(/i;
32
+
33
+ /** Spinner / thinking indicators. */
34
+ const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|Thinking|Generating/i;
35
+
36
+ /**
37
+ * Detect the current operational status of a Claude Code terminal.
38
+ *
39
+ * @param {string[]} lines - Array of ANSI-stripped lines (from LineBuffer)
40
+ * @returns {'idle'|'working'|'waiting_approval'|'compacting'|'done'|'blocked'|'error'}
41
+ */
42
+ function detectStatus(lines) {
43
+ if (!lines || lines.length === 0) return 'idle';
44
+
45
+ // Work with trimmed, non-empty lines
46
+ const trimmed = lines.map(l => l.trim()).filter(Boolean);
47
+ if (trimmed.length === 0) return 'idle';
48
+
49
+ const last50 = trimmed.slice(-50).join('\n');
50
+ const contentLines = trimmed.filter(l => !STATUS_BAR_NOISE.test(l));
51
+ const lastContentLine = contentLines.slice(-1)[0] || '';
52
+ const last10 = trimmed.slice(-10);
53
+
54
+ // Prompt detection — idle if prompt is visible and no recent tool work
55
+ const hasPrompt = last10.some(l => /^[>❯]$/.test(l));
56
+ const hasShortcutsHint = last10.some(
57
+ l => /\?.*for\s*shortcuts/i.test(l) || l === '?forshortcuts'
58
+ );
59
+
60
+ if (hasPrompt || hasShortcutsHint) {
61
+ const recentWork = last10.some(l => TOOL_RE.test(l));
62
+ if (!recentWork) return 'idle';
63
+ }
64
+
65
+ // Approval prompts
66
+ if (/Select any you wish to enable|Space to select|Enter to confirm/i.test(last50)) {
67
+ return 'waiting_approval';
68
+ }
69
+ if (/accept edits|allow bash|Yes\/No|\(y\/n\)/i.test(last50)) {
70
+ return 'waiting_approval';
71
+ }
72
+
73
+ // Auto-compaction
74
+ if (/auto-compact|compressing|compacting/i.test(last50)) return 'compacting';
75
+
76
+ // Explicit status markers (convention for orchestrator scripts)
77
+ if (/STATUS: DONE/i.test(last50)) return 'done';
78
+ if (/STATUS: BLOCKED/i.test(last50)) return 'blocked';
79
+
80
+ // Active tool use
81
+ if (TOOL_RE.test(last50)) return 'working';
82
+
83
+ // Spinner / thinking
84
+ if (SPINNER_RE.test(lastContentLine)) return 'working';
85
+
86
+ // Error detection in very recent output
87
+ const last3 = contentLines.slice(-3).join('\n');
88
+ if (/panic:|Traceback \(most recent/i.test(last3)) return 'error';
89
+
90
+ // Default to working (Claude is likely generating)
91
+ return 'working';
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Context window percentage extraction
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Extract context-window usage percentage from terminal output.
100
+ * Looks for patterns like "Context: 42%" or "context window: 72%".
101
+ *
102
+ * @param {string[]} lines - Array of ANSI-stripped lines
103
+ * @returns {number|null} Percentage (0-100) or null if not found
104
+ */
105
+ function extractContextPct(lines) {
106
+ if (!lines || lines.length === 0) return null;
107
+
108
+ // Scan from the end — most recent value wins
109
+ for (let i = lines.length - 1; i >= 0; i--) {
110
+ const match = lines[i].match(/context(?:\s*window)?[:\s]+(\d{1,3})%/i);
111
+ if (match) {
112
+ const pct = parseInt(match[1], 10);
113
+ if (pct >= 0 && pct <= 100) return pct;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Structured event extraction
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * @typedef {Object} StructuredEvent
125
+ * @property {string} ts - ISO 8601 timestamp
126
+ * @property {string} type - Event type: status | progress | tool | build | error | need | contract | context
127
+ * @property {string} terminal - Terminal label (e.g. "T1")
128
+ * @property {string} msg - Raw message content
129
+ * @property {Object} [meta] - Type-specific metadata
130
+ */
131
+
132
+ /**
133
+ * Line prefix patterns for structured events.
134
+ * Convention: lines prefixed with `STATUS:`, `PROGRESS:`, `NEED:`, etc.
135
+ * are treated as structured events emitted by orchestrated Claude sessions.
136
+ */
137
+ const EVENT_PATTERNS = [
138
+ { re: /^STATUS:\s*(.+)/i, type: 'status' },
139
+ { re: /^PROGRESS:\s*(.+)/i, type: 'progress' },
140
+ { re: /^NEED:\s*(.+)/i, type: 'need' },
141
+ { re: /^CONTRACT:\s*(.+)/i, type: 'contract' },
142
+ { re: /^BUILD:\s*(.+)/i, type: 'build' },
143
+ { re: /^INSIGHT:\s*(.+)/i, type: 'insight' },
144
+ { re: /^PLAYBOOK:\s*(.+)/i, type: 'playbook' },
145
+ { re: /^ERROR_RESOLVED:\s*(.+)/i, type: 'error_resolved' },
146
+ ];
147
+
148
+ /** Tool invocation pattern for extracting tool events. */
149
+ const TOOL_INVOKE_RE = /^(Bash|Read|Edit|Write|Grep|Glob|Agent)\((.+)\)/;
150
+
151
+ /** Error pattern. */
152
+ const ERROR_RE = /^(Error|panic:|Traceback \(most recent|FATAL|ENOENT|EACCES)/i;
153
+
154
+ /** Context window pattern. */
155
+ const CONTEXT_RE = /context(?:\s*window)?[:\s]+(\d{1,3})%/i;
156
+
157
+ /**
158
+ * Parse an array of stripped lines into structured JSONL-compatible event objects.
159
+ *
160
+ * @param {string[]} lines - ANSI-stripped lines to parse
161
+ * @param {string} terminalLabel - Label for the terminal (e.g. "T1")
162
+ * @returns {StructuredEvent[]}
163
+ */
164
+ function extractStructuredEvents(lines, terminalLabel) {
165
+ if (!lines || lines.length === 0) return [];
166
+
167
+ const events = [];
168
+ const ts = new Date().toISOString();
169
+
170
+ for (const line of lines) {
171
+ const trimmed = line.trim();
172
+ if (!trimmed) continue;
173
+
174
+ // Check structured prefixes first
175
+ let matched = false;
176
+ for (const { re, type } of EVENT_PATTERNS) {
177
+ const m = trimmed.match(re);
178
+ if (m) {
179
+ events.push({ ts, type, terminal: terminalLabel, msg: m[1].trim() });
180
+ matched = true;
181
+ break;
182
+ }
183
+ }
184
+ if (matched) continue;
185
+
186
+ // Tool invocations
187
+ const toolMatch = trimmed.match(TOOL_INVOKE_RE);
188
+ if (toolMatch) {
189
+ events.push({
190
+ ts,
191
+ type: 'tool',
192
+ terminal: terminalLabel,
193
+ msg: trimmed,
194
+ meta: { tool: toolMatch[1], args: toolMatch[2] },
195
+ });
196
+ continue;
197
+ }
198
+
199
+ // Errors
200
+ if (ERROR_RE.test(trimmed)) {
201
+ events.push({ ts, type: 'error', terminal: terminalLabel, msg: trimmed });
202
+ continue;
203
+ }
204
+
205
+ // Context window updates
206
+ const ctxMatch = trimmed.match(CONTEXT_RE);
207
+ if (ctxMatch) {
208
+ const pct = parseInt(ctxMatch[1], 10);
209
+ if (pct >= 0 && pct <= 100) {
210
+ events.push({
211
+ ts,
212
+ type: 'context',
213
+ terminal: terminalLabel,
214
+ msg: trimmed,
215
+ meta: { pct },
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ return events;
222
+ }
223
+
224
+ module.exports = {
225
+ stripAnsi,
226
+ detectStatus,
227
+ extractContextPct,
228
+ extractStructuredEvents,
229
+ };