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,458 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Maximum reset timeout for circuit breaker doubling (5 minutes).
5
+ */
6
+ const MAX_RESET_TIMEOUT = 5 * 60 * 1000;
7
+
8
+ /**
9
+ * Circuit breaker states.
10
+ */
11
+ const STATE_CLOSED = 'CLOSED';
12
+ const STATE_OPEN = 'OPEN';
13
+ const STATE_HALF_OPEN = 'HALF_OPEN';
14
+
15
+ /**
16
+ * Per-terminal circuit breaker implementing the standard three-state pattern.
17
+ *
18
+ * State machine:
19
+ * CLOSED --[threshold failures]--> OPEN
20
+ * OPEN --[resetTimeout elapsed]--> HALF_OPEN
21
+ * HALF_OPEN --[success]--> CLOSED
22
+ * HALF_OPEN --[failure]--> OPEN (with doubled resetTimeout, max 5 min)
23
+ */
24
+ class CircuitBreaker {
25
+ /**
26
+ * @param {number} terminalId - The terminal this breaker protects
27
+ * @param {object} [options]
28
+ * @param {number} [options.threshold=3] - Consecutive failures before opening
29
+ * @param {number} [options.resetTimeout=60000] - Ms before OPEN transitions to HALF_OPEN
30
+ */
31
+ constructor(terminalId, { threshold = 3, resetTimeout = 60000 } = {}) {
32
+ if (typeof terminalId !== 'number') {
33
+ throw new Error('terminalId must be a number');
34
+ }
35
+ if (typeof threshold !== 'number' || threshold < 1) {
36
+ throw new Error('threshold must be a positive number');
37
+ }
38
+ if (typeof resetTimeout !== 'number' || resetTimeout < 0) {
39
+ throw new Error('resetTimeout must be a non-negative number');
40
+ }
41
+
42
+ this._terminalId = terminalId;
43
+ this._threshold = threshold;
44
+ this._resetTimeout = resetTimeout;
45
+ this._currentResetTimeout = resetTimeout;
46
+ this._state = STATE_CLOSED;
47
+ this._failures = 0;
48
+ this._lastFailureAt = null;
49
+ this._openedAt = null;
50
+ }
51
+
52
+ /**
53
+ * Record a failure. Increments failure count and opens the circuit
54
+ * if the threshold is reached.
55
+ * In HALF_OPEN state, a failure reopens the circuit with doubled timeout.
56
+ */
57
+ recordFailure() {
58
+ this._failures++;
59
+ this._lastFailureAt = Date.now();
60
+
61
+ if (this._state === STATE_HALF_OPEN) {
62
+ // Failure during probe — reopen with doubled timeout
63
+ this._state = STATE_OPEN;
64
+ this._openedAt = Date.now();
65
+ this._currentResetTimeout = Math.min(
66
+ this._currentResetTimeout * 2,
67
+ MAX_RESET_TIMEOUT
68
+ );
69
+ } else if (this._state === STATE_CLOSED && this._failures >= this._threshold) {
70
+ this._state = STATE_OPEN;
71
+ this._openedAt = Date.now();
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Record a success. Resets failure count and closes the circuit.
77
+ */
78
+ recordSuccess() {
79
+ this._failures = 0;
80
+ this._state = STATE_CLOSED;
81
+ this._currentResetTimeout = this._resetTimeout;
82
+ this._openedAt = null;
83
+ }
84
+
85
+ /**
86
+ * Check whether this terminal can accept a new task.
87
+ *
88
+ * - CLOSED: always true
89
+ * - OPEN: if resetTimeout has elapsed, transition to HALF_OPEN and return true (allow one probe)
90
+ * - HALF_OPEN: false (a probe task is already in flight)
91
+ *
92
+ * @returns {boolean}
93
+ */
94
+ canAcceptTask() {
95
+ if (this._state === STATE_CLOSED) return true;
96
+
97
+ if (this._state === STATE_OPEN) {
98
+ const elapsed = Date.now() - this._openedAt;
99
+ if (elapsed >= this._currentResetTimeout) {
100
+ this._state = STATE_HALF_OPEN;
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ // HALF_OPEN — one test task already in flight
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Current circuit breaker state.
112
+ * @returns {'CLOSED'|'OPEN'|'HALF_OPEN'}
113
+ */
114
+ get state() {
115
+ return this._state;
116
+ }
117
+
118
+ /**
119
+ * Current consecutive failure count.
120
+ * @returns {number}
121
+ */
122
+ get failureCount() {
123
+ return this._failures;
124
+ }
125
+
126
+ /**
127
+ * Serialize circuit breaker state.
128
+ * @returns {object}
129
+ */
130
+ toJSON() {
131
+ return {
132
+ terminalId: this._terminalId,
133
+ state: this._state,
134
+ failures: this._failures,
135
+ threshold: this._threshold,
136
+ resetTimeout: this._resetTimeout,
137
+ currentResetTimeout: this._currentResetTimeout,
138
+ lastFailureAt: this._lastFailureAt,
139
+ openedAt: this._openedAt,
140
+ };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Global retry budget that limits retries within a sliding time window.
146
+ * Prevents retry storms from overwhelming the system.
147
+ */
148
+ class RetryBudget {
149
+ /**
150
+ * @param {object} [options]
151
+ * @param {number} [options.maxRetries=10] - Maximum retries allowed within the window
152
+ * @param {number} [options.windowMs=600000] - Sliding window duration (default 10 min)
153
+ */
154
+ constructor({ maxRetries = 10, windowMs = 600000 } = {}) {
155
+ if (typeof maxRetries !== 'number' || maxRetries < 0) {
156
+ throw new Error('maxRetries must be a non-negative number');
157
+ }
158
+ if (typeof windowMs !== 'number' || windowMs < 0) {
159
+ throw new Error('windowMs must be a non-negative number');
160
+ }
161
+
162
+ this._maxRetries = maxRetries;
163
+ this._windowMs = windowMs;
164
+ /** @type {number[]} */
165
+ this._timestamps = [];
166
+ }
167
+
168
+ /**
169
+ * Prune expired timestamps and check if a retry is allowed.
170
+ *
171
+ * @returns {boolean} true if under budget
172
+ */
173
+ canRetry() {
174
+ this._prune();
175
+ return this._timestamps.length < this._maxRetries;
176
+ }
177
+
178
+ /**
179
+ * Record a retry. Adds the current timestamp to the window.
180
+ */
181
+ recordRetry() {
182
+ this._timestamps.push(Date.now());
183
+ }
184
+
185
+ /**
186
+ * Number of retries remaining in the current window.
187
+ * @returns {number}
188
+ */
189
+ get remaining() {
190
+ this._prune();
191
+ return Math.max(0, this._maxRetries - this._timestamps.length);
192
+ }
193
+
194
+ /**
195
+ * Serialize retry budget state.
196
+ * @returns {object}
197
+ */
198
+ toJSON() {
199
+ this._prune();
200
+ return {
201
+ maxRetries: this._maxRetries,
202
+ windowMs: this._windowMs,
203
+ timestamps: [...this._timestamps],
204
+ remaining: this.remaining,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Remove timestamps older than the sliding window.
210
+ * @private
211
+ */
212
+ _prune() {
213
+ const cutoff = Date.now() - this._windowMs;
214
+ this._timestamps = this._timestamps.filter((ts) => ts > cutoff);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Supervisor that manages terminal restart decisions using Erlang-style strategies.
220
+ * Enforces: "no more than maxRestarts within withinSeconds seconds."
221
+ *
222
+ * Strategies (informational — the caller decides what to do):
223
+ * - one_for_one: only the failed terminal is restarted
224
+ * - one_for_all: all terminals are restarted when one fails
225
+ * - rest_for_one: the failed terminal and all started after it are restarted
226
+ */
227
+ class Supervisor {
228
+ /**
229
+ * @param {object} [options]
230
+ * @param {'one_for_one'|'one_for_all'|'rest_for_one'} [options.strategy='one_for_one']
231
+ * @param {number} [options.maxRestarts=3] - Maximum restarts within the window
232
+ * @param {number} [options.withinSeconds=300] - Window duration in seconds (default 5 min)
233
+ */
234
+ constructor({ strategy = 'one_for_one', maxRestarts = 3, withinSeconds = 300 } = {}) {
235
+ const validStrategies = ['one_for_one', 'one_for_all', 'rest_for_one'];
236
+ if (!validStrategies.includes(strategy)) {
237
+ throw new Error(
238
+ `Invalid strategy "${strategy}". Must be one of: ${validStrategies.join(', ')}`
239
+ );
240
+ }
241
+ if (typeof maxRestarts !== 'number' || maxRestarts < 0) {
242
+ throw new Error('maxRestarts must be a non-negative number');
243
+ }
244
+ if (typeof withinSeconds !== 'number' || withinSeconds < 0) {
245
+ throw new Error('withinSeconds must be a non-negative number');
246
+ }
247
+
248
+ this._strategy = strategy;
249
+ this._maxRestarts = maxRestarts;
250
+ this._withinMs = withinSeconds * 1000;
251
+ /** @type {Map<number, number[]>} terminalId -> [restart timestamps] */
252
+ this._restarts = new Map();
253
+ }
254
+
255
+ /**
256
+ * Record a restart for a terminal and check if it's within the allowed budget.
257
+ *
258
+ * @param {number} terminalId - Terminal being restarted
259
+ * @returns {boolean} true if restart is allowed, false if max exceeded
260
+ */
261
+ recordRestart(terminalId) {
262
+ if (typeof terminalId !== 'number') {
263
+ throw new Error('terminalId must be a number');
264
+ }
265
+
266
+ if (!this._restarts.has(terminalId)) {
267
+ this._restarts.set(terminalId, []);
268
+ }
269
+
270
+ const timestamps = this._restarts.get(terminalId);
271
+ this._pruneTimestamps(timestamps);
272
+
273
+ if (timestamps.length >= this._maxRestarts) {
274
+ return false;
275
+ }
276
+
277
+ timestamps.push(Date.now());
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Check if a restart would be allowed without actually recording it.
283
+ *
284
+ * @param {number} terminalId - Terminal to check
285
+ * @returns {boolean} true if a restart is currently allowed
286
+ */
287
+ shouldRestart(terminalId) {
288
+ if (typeof terminalId !== 'number') {
289
+ throw new Error('terminalId must be a number');
290
+ }
291
+
292
+ if (!this._restarts.has(terminalId)) return true;
293
+
294
+ const timestamps = [...this._restarts.get(terminalId)];
295
+ const cutoff = Date.now() - this._withinMs;
296
+ const recent = timestamps.filter((ts) => ts > cutoff);
297
+ return recent.length < this._maxRestarts;
298
+ }
299
+
300
+ /**
301
+ * Clear restart history for a specific terminal.
302
+ *
303
+ * @param {number} terminalId - Terminal to reset
304
+ */
305
+ reset(terminalId) {
306
+ if (typeof terminalId !== 'number') {
307
+ throw new Error('terminalId must be a number');
308
+ }
309
+ this._restarts.delete(terminalId);
310
+ }
311
+
312
+ /**
313
+ * Clear restart history for all terminals.
314
+ */
315
+ resetAll() {
316
+ this._restarts.clear();
317
+ }
318
+
319
+ /**
320
+ * Get the current restart count within the window for a terminal.
321
+ *
322
+ * @param {number} terminalId - Terminal to check
323
+ * @returns {number}
324
+ */
325
+ getRestartCount(terminalId) {
326
+ if (typeof terminalId !== 'number') {
327
+ throw new Error('terminalId must be a number');
328
+ }
329
+ if (!this._restarts.has(terminalId)) return 0;
330
+
331
+ const timestamps = this._restarts.get(terminalId);
332
+ this._pruneTimestamps(timestamps);
333
+ return timestamps.length;
334
+ }
335
+
336
+ /**
337
+ * Serialize supervisor state.
338
+ * @returns {object}
339
+ */
340
+ toJSON() {
341
+ const restarts = {};
342
+ for (const [id, timestamps] of this._restarts) {
343
+ this._pruneTimestamps(timestamps);
344
+ restarts[id] = [...timestamps];
345
+ }
346
+ return {
347
+ strategy: this._strategy,
348
+ maxRestarts: this._maxRestarts,
349
+ withinSeconds: this._withinMs / 1000,
350
+ restarts,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Remove timestamps outside the current window (mutates array in place).
356
+ * @private
357
+ * @param {number[]} timestamps
358
+ */
359
+ _pruneTimestamps(timestamps) {
360
+ const cutoff = Date.now() - this._withinMs;
361
+ let i = 0;
362
+ while (i < timestamps.length) {
363
+ if (timestamps[i] <= cutoff) {
364
+ timestamps.splice(i, 1);
365
+ } else {
366
+ i++;
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Error type definitions with retryable flag and recommended action.
374
+ * @typedef {{ type: string, retryable: boolean, action: string }} ClassifiedError
375
+ */
376
+
377
+ /**
378
+ * Parse a STATUS: ERROR line and classify it into a typed error
379
+ * with retry eligibility and recommended action.
380
+ *
381
+ * @param {string} errorText - Raw error text from a terminal
382
+ * @returns {ClassifiedError} Classified error with type, retryable, and action
383
+ */
384
+ function classifyError(errorText) {
385
+ if (typeof errorText !== 'string') {
386
+ return { type: 'TOOL_FAIL', retryable: true, action: 'retry' };
387
+ }
388
+
389
+ const lower = errorText.toLowerCase();
390
+
391
+ // Context full / compaction needed
392
+ if (
393
+ (lower.includes('context') && lower.includes('full')) ||
394
+ lower.includes('compac')
395
+ ) {
396
+ return { type: 'CONTEXT_FULL', retryable: false, action: 'restart' };
397
+ }
398
+
399
+ // Dependency / waiting for another task
400
+ if (
401
+ lower.includes('need:') ||
402
+ lower.includes('dependency') ||
403
+ lower.includes('waiting for')
404
+ ) {
405
+ return { type: 'DEPENDENCY', retryable: false, action: 'route' };
406
+ }
407
+
408
+ // Rate limiting
409
+ if (
410
+ lower.includes('rate limit') ||
411
+ lower.includes('429') ||
412
+ lower.includes('too many requests')
413
+ ) {
414
+ return { type: 'RATE_LIMIT', retryable: true, action: 'wait' };
415
+ }
416
+
417
+ // Timeout
418
+ if (lower.includes('timeout') || lower.includes('timed out')) {
419
+ return { type: 'TIMEOUT', retryable: true, action: 'retry' };
420
+ }
421
+
422
+ // Stuck / loop
423
+ if (
424
+ lower.includes('stuck') ||
425
+ lower.includes('loop') ||
426
+ lower.includes('same output')
427
+ ) {
428
+ return { type: 'STUCK', retryable: false, action: 'escalate' };
429
+ }
430
+
431
+ // Validation errors
432
+ if (
433
+ lower.includes('validation') ||
434
+ (lower.includes('expected') && lower.includes('actual'))
435
+ ) {
436
+ return { type: 'VALIDATION', retryable: true, action: 'retry' };
437
+ }
438
+
439
+ // Crash / process exit
440
+ if (
441
+ lower.includes('crash') ||
442
+ lower.includes('exit') ||
443
+ lower.includes('sigterm') ||
444
+ lower.includes('sigkill')
445
+ ) {
446
+ return { type: 'CRASH', retryable: false, action: 'restart' };
447
+ }
448
+
449
+ // Default: assume tool failure, retryable
450
+ return { type: 'TOOL_FAIL', retryable: true, action: 'retry' };
451
+ }
452
+
453
+ module.exports = {
454
+ CircuitBreaker,
455
+ RetryBudget,
456
+ Supervisor,
457
+ classifyError,
458
+ };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Circular buffer of ANSI-stripped text lines.
5
+ * Overwrites oldest entries when capacity is reached.
6
+ */
7
+ class LineBuffer {
8
+ /**
9
+ * @param {number} [maxLines=1000] - Maximum number of lines to retain
10
+ */
11
+ constructor(maxLines = 1000) {
12
+ this._maxLines = maxLines;
13
+ this._buf = new Array(maxLines);
14
+ this._head = 0; // next write position
15
+ this._count = 0; // total lines stored (capped at maxLines)
16
+ }
17
+
18
+ /**
19
+ * Append a single line to the buffer. Overwrites the oldest line when full.
20
+ * @param {string} line
21
+ */
22
+ push(line) {
23
+ this._buf[this._head] = line;
24
+ this._head = (this._head + 1) % this._maxLines;
25
+ if (this._count < this._maxLines) this._count++;
26
+ }
27
+
28
+ /**
29
+ * Return the last n lines (most recent first order is NOT used;
30
+ * lines are returned in chronological order, oldest-to-newest).
31
+ * @param {number} n - Number of recent lines to retrieve
32
+ * @returns {string[]}
33
+ */
34
+ last(n) {
35
+ const count = Math.min(n, this._count);
36
+ if (count === 0) return [];
37
+ const result = new Array(count);
38
+ // start = position of the (count)-th line from the end
39
+ const start = (this._head - count + this._maxLines) % this._maxLines;
40
+ for (let i = 0; i < count; i++) {
41
+ result[i] = this._buf[(start + i) % this._maxLines];
42
+ }
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Return a window of lines from the buffer.
48
+ * @param {number} offset - Number of lines from the end to start (0 = most recent)
49
+ * @param {number} limit - Maximum number of lines to return
50
+ * @returns {{ lines: string[], total: number, truncated: boolean }}
51
+ */
52
+ slice(offset, limit) {
53
+ const total = this._count;
54
+ if (total === 0 || offset >= total) {
55
+ return { lines: [], total, truncated: false };
56
+ }
57
+ // offset 0 means "start from the newest line going backwards"
58
+ // We want to return `limit` lines ending at `total - offset`
59
+ const end = total - offset; // exclusive upper bound (in logical order)
60
+ const start = Math.max(0, end - limit); // inclusive lower bound
61
+ const count = end - start;
62
+
63
+ const result = new Array(count);
64
+ const bufStart = (this._head - total + start + this._maxLines) % this._maxLines;
65
+ for (let i = 0; i < count; i++) {
66
+ result[i] = this._buf[(bufStart + i) % this._maxLines];
67
+ }
68
+ return {
69
+ lines: result,
70
+ total,
71
+ truncated: end < total,
72
+ };
73
+ }
74
+
75
+ /** Remove all stored lines. */
76
+ clear() {
77
+ this._head = 0;
78
+ this._count = 0;
79
+ }
80
+
81
+ /** @returns {number} Number of lines currently stored */
82
+ get length() {
83
+ return this._count;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Circular byte buffer that retains raw PTY output (ANSI codes intact).
89
+ * Trims from the front when the maximum size is exceeded.
90
+ */
91
+ class RawBuffer {
92
+ /**
93
+ * @param {number} [maxBytes=65536] - Maximum buffer size in bytes
94
+ */
95
+ constructor(maxBytes = 65536) {
96
+ this._maxBytes = maxBytes;
97
+ this._data = '';
98
+ }
99
+
100
+ /**
101
+ * Append string data to the buffer, trimming from the front if needed.
102
+ * @param {string} data - Raw PTY output
103
+ */
104
+ push(data) {
105
+ this._data += data;
106
+ if (this._data.length > this._maxBytes) {
107
+ this._data = this._data.slice(this._data.length - this._maxBytes);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Return the full buffer contents as a string.
113
+ * @returns {string}
114
+ */
115
+ getAll() {
116
+ return this._data;
117
+ }
118
+
119
+ /** Clear the buffer. */
120
+ clear() {
121
+ this._data = '';
122
+ }
123
+ }
124
+
125
+ module.exports = { LineBuffer, RawBuffer };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /** Files that must NEVER be modified by the self-improvement system. */
7
+ const IMMUTABLE_FILES = ['identity.md', 'security-protocol.md'];
8
+
9
+ /**
10
+ * Check if a file path points to an immutable file.
11
+ * @param {string} filePath
12
+ * @returns {boolean}
13
+ */
14
+ function isImmutable(filePath) {
15
+ return IMMUTABLE_FILES.includes(path.basename(filePath));
16
+ }
17
+
18
+ /**
19
+ * Atomically write content to a file (temp + rename).
20
+ * Throws if the file is immutable.
21
+ * @param {string} filePath
22
+ * @param {string} content
23
+ */
24
+ function safeWrite(filePath, content) {
25
+ if (isImmutable(filePath)) {
26
+ throw new Error(`Cannot write to immutable file: ${path.basename(filePath)}`);
27
+ }
28
+ const dir = path.dirname(filePath);
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ const tmp = `${filePath}.tmp.${process.pid}`;
31
+ fs.writeFileSync(tmp, content, 'utf8');
32
+ fs.renameSync(tmp, filePath);
33
+ return filePath;
34
+ }
35
+
36
+ /**
37
+ * Append a line to a file. Creates the file and directory if needed.
38
+ * Throws if the file is immutable.
39
+ * @param {string} filePath
40
+ * @param {string} line
41
+ */
42
+ function safeAppend(filePath, line) {
43
+ if (isImmutable(filePath)) {
44
+ throw new Error(`Cannot append to immutable file: ${path.basename(filePath)}`);
45
+ }
46
+ const dir = path.dirname(filePath);
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ fs.appendFileSync(filePath, line + '\n', 'utf8');
49
+ }
50
+
51
+ module.exports = { isImmutable, safeWrite, safeAppend };