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.
- package/CLAUDE.md +121 -0
- package/ORCHESTRATOR-PROMPT.md +295 -0
- package/cli.js +117 -0
- package/lib/analyze-session.js +92 -0
- package/lib/evolution-writer.js +27 -0
- package/lib/permissions.js +311 -0
- package/lib/playbook-tracker.js +85 -0
- package/lib/resilience.js +458 -0
- package/lib/ring-buffer.js +125 -0
- package/lib/safe-file-writer.js +51 -0
- package/lib/scheduler.js +212 -0
- package/lib/settings-gen.js +159 -0
- package/lib/sse.js +103 -0
- package/lib/status-detect.js +229 -0
- package/lib/task-dag.js +547 -0
- package/lib/tool-rater.js +63 -0
- package/orchestrator/evolution-log.md +33 -0
- package/orchestrator/identity.md +60 -0
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +54 -0
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +55 -0
- package/orchestrator/playbooks.md +71 -0
- package/orchestrator/security-protocol.md +69 -0
- package/orchestrator/tool-registry.md +96 -0
- package/package.json +46 -0
- package/public/app.js +860 -0
- package/public/index.html +60 -0
- package/public/style.css +678 -0
- package/server.js +695 -0
package/lib/scheduler.js
ADDED
|
@@ -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
|
+
};
|