@mmmbuto/nexuscrew 0.9.14 → 0.9.16
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/frontend/dist/assets/index-CprIQlC5.css +32 -0
- package/frontend/dist/assets/index-CwsGpQwK.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/receipt.js +1 -1
- package/lib/cells/routes.js +26 -9
- package/lib/config.js +12 -0
- package/lib/diagnostics/store.js +4 -1
- package/lib/fleet/builtin.js +53 -17
- package/lib/fleet/catalogs/opencode-go.json +3 -3
- package/lib/fleet/definitions.js +29 -10
- package/lib/fleet/managed.js +86 -15
- package/lib/live-host/bridge.js +156 -8
- package/lib/live-host/routes.js +10 -2
- package/lib/mcp/server.js +28 -6
- package/lib/mcp/tools.js +24 -6
- package/lib/nodes/health.js +43 -9
- package/lib/server.js +23 -3
- package/lib/ws/bridge.js +243 -24
- package/lib/ws/drop-counter.js +37 -0
- package/package.json +1 -1
- package/skills/nexuscrew/SKILL.md +1 -1
- package/frontend/dist/assets/index-9jCxHZwQ.js +0 -93
- package/frontend/dist/assets/index-keXh4CAm.css +0 -32
package/lib/ws/bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const crypto = require('node:crypto');
|
|
2
3
|
// Bridges ONE WebSocket to ONE PTY attach. Dependencies are injectable for tests.
|
|
3
4
|
// Hardening: close on protocol violation, no 2nd attach, clamp cols/rows,
|
|
4
5
|
// validated session, backpressure cutoff, errors as JSON with a code.
|
|
@@ -11,19 +12,157 @@ function clamp(n, lo, hi, def) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const ATTACH_TIMEOUT_MS = 15000;
|
|
15
|
+
// Mobile handovers and tunnel renegotiation can last longer than a LAN retry.
|
|
16
|
+
// Thirty seconds is finite but configurable, so a small host can tune the
|
|
17
|
+
// trade-off without making the suspended PTY lifetime unbounded.
|
|
18
|
+
const PTY_GRACE_MS = 30000;
|
|
19
|
+
const PTY_GRACE_MAX_SESSIONS = 8;
|
|
20
|
+
// No PTY output is buffered while detached; this bounds the retained record,
|
|
21
|
+
// rather than pretending a stale output buffer is a current terminal screen.
|
|
22
|
+
const PTY_GRACE_RECORD_BYTES = 1024;
|
|
23
|
+
const PTY_GRACE_MAX_MEMORY_BYTES = PTY_GRACE_MAX_SESSIONS * PTY_GRACE_RECORD_BYTES;
|
|
24
|
+
|
|
25
|
+
function createPtyGraceStore({
|
|
26
|
+
graceMs = PTY_GRACE_MS,
|
|
27
|
+
maxSessions = PTY_GRACE_MAX_SESSIONS,
|
|
28
|
+
maxMemoryBytes = PTY_GRACE_MAX_MEMORY_BYTES,
|
|
29
|
+
recordBytes = PTY_GRACE_RECORD_BYTES,
|
|
30
|
+
randomBytes = crypto.randomBytes,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const suspended = new Map();
|
|
33
|
+
const sessionLimit = Math.max(1, Math.floor(Number(maxSessions) || PTY_GRACE_MAX_SESSIONS));
|
|
34
|
+
const memoryLimit = Math.max(1, Math.floor(Number(maxMemoryBytes) || PTY_GRACE_MAX_MEMORY_BYTES));
|
|
35
|
+
const retainedRecordBytes = Math.max(1, Math.floor(Number(recordBytes) || PTY_GRACE_RECORD_BYTES));
|
|
36
|
+
let memoryBytes = 0;
|
|
37
|
+
|
|
38
|
+
const clearRecordTimer = (record) => {
|
|
39
|
+
if (record.graceTimer) clearTimeout(record.graceTimer);
|
|
40
|
+
record.graceTimer = null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const issueToken = (record) => {
|
|
44
|
+
if (!record.resumeToken) record.resumeToken = randomBytes(32).toString('base64url');
|
|
45
|
+
return record.resumeToken;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const releaseRecord = (token, record) => {
|
|
49
|
+
if (suspended.get(token) !== record) return false;
|
|
50
|
+
suspended.delete(token);
|
|
51
|
+
memoryBytes = Math.max(0, memoryBytes - record.graceBytes);
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const suspend = (record) => {
|
|
56
|
+
if (!record || record.ended) return null;
|
|
57
|
+
const token = issueToken(record);
|
|
58
|
+
if (!suspended.has(token)
|
|
59
|
+
&& (suspended.size >= sessionLimit || memoryBytes + retainedRecordBytes > memoryLimit)) return null;
|
|
60
|
+
clearRecordTimer(record);
|
|
61
|
+
if (!suspended.has(token)) {
|
|
62
|
+
record.graceBytes = retainedRecordBytes;
|
|
63
|
+
memoryBytes += retainedRecordBytes;
|
|
64
|
+
}
|
|
65
|
+
suspended.set(token, record);
|
|
66
|
+
record.graceTimer = setTimeout(() => {
|
|
67
|
+
if (suspended.get(token) !== record) return;
|
|
68
|
+
releaseRecord(token, record);
|
|
69
|
+
record.graceTimer = null;
|
|
70
|
+
record.graceExpired = true;
|
|
71
|
+
try { record.pty.kill(); } catch (_) {}
|
|
72
|
+
}, graceMs);
|
|
73
|
+
if (typeof record.graceTimer.unref === 'function') record.graceTimer.unref();
|
|
74
|
+
return token;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const resume = (token, session) => {
|
|
78
|
+
if (!token || typeof session !== 'string') return null;
|
|
79
|
+
const record = suspended.get(token);
|
|
80
|
+
if (!record || record.ended || record.session !== session || record.ws) return null;
|
|
81
|
+
releaseRecord(token, record);
|
|
82
|
+
clearRecordTimer(record);
|
|
83
|
+
return record;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const resumeEnded = (token, session) => {
|
|
87
|
+
if (!token || typeof session !== 'string') return null;
|
|
88
|
+
const record = suspended.get(token);
|
|
89
|
+
if (!record || !record.ended || record.session !== session || record.ws) return null;
|
|
90
|
+
releaseRecord(token, record);
|
|
91
|
+
clearRecordTimer(record);
|
|
92
|
+
return record;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const close = () => {
|
|
96
|
+
for (const record of suspended.values()) {
|
|
97
|
+
clearRecordTimer(record);
|
|
98
|
+
try { record.pty.kill(); } catch (_) {}
|
|
99
|
+
}
|
|
100
|
+
suspended.clear();
|
|
101
|
+
memoryBytes = 0;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
issueToken,
|
|
106
|
+
suspend,
|
|
107
|
+
resume,
|
|
108
|
+
resumeEnded,
|
|
109
|
+
close,
|
|
110
|
+
size: () => suspended.size,
|
|
111
|
+
memoryBytes: () => memoryBytes,
|
|
112
|
+
limits: () => ({ maxSessions: sessionLimit, maxMemoryBytes: memoryLimit }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sendJson(ws, value) {
|
|
117
|
+
try { ws.send(JSON.stringify(value)); } catch (_) {}
|
|
118
|
+
}
|
|
14
119
|
|
|
15
120
|
function bindWs(ws, deps) {
|
|
16
|
-
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {} } = deps;
|
|
17
|
-
let
|
|
121
|
+
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null } = deps;
|
|
122
|
+
let record = null;
|
|
18
123
|
let attached = false;
|
|
19
124
|
let session = null;
|
|
125
|
+
let closeLogged = false;
|
|
126
|
+
// UNA sola riga diagnostica per socket: la prima chiusura vince. Le
|
|
127
|
+
// successive (close + error sullo stesso socket) non generano rumore.
|
|
128
|
+
const logClose = (level, event, message, meta = {}) => {
|
|
129
|
+
if (closeLogged) return;
|
|
130
|
+
closeLogged = true;
|
|
131
|
+
if (!diagnostics) return;
|
|
132
|
+
try { diagnostics.record(level, 'ws', event, message, { cell: session || undefined, ...meta }); } catch (_) {}
|
|
133
|
+
};
|
|
134
|
+
const countDrop = () => {
|
|
135
|
+
if (!dropCounter) return {};
|
|
136
|
+
const snap = dropCounter.recordDrop(session);
|
|
137
|
+
return { drops: snap.drops };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const detach = () => {
|
|
141
|
+
if (!record || record.ws !== ws) return;
|
|
142
|
+
record.ws = null;
|
|
143
|
+
if (record.ended) return;
|
|
144
|
+
if (!ptyGrace) {
|
|
145
|
+
try { record.pty.kill(); } catch (_) {}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!ptyGrace.suspend(record)) {
|
|
149
|
+
// A bounded host refuses another suspended PTY instead of exceeding its
|
|
150
|
+
// explicit process/memory budget.
|
|
151
|
+
try { record.pty.kill(); } catch (_) {}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
20
154
|
|
|
21
155
|
function fail(code, reason) {
|
|
22
156
|
// Si spegne anche la scadenza pre-attach: un frame rifiutato (token o
|
|
23
157
|
// handshake non validi) chiude gia' il socket, e lasciare il timer vivo
|
|
24
158
|
// fino al close event tiene in piedi un handle senza scopo.
|
|
25
159
|
clearAttachTimer();
|
|
26
|
-
|
|
160
|
+
sendJson(ws, { type: 'error', reason });
|
|
161
|
+
// Chiusura INITIATA DAL SERVER: il motivo e' qui, non nel close event.
|
|
162
|
+
// Level warn = sempre visibile (non richiede verbose).
|
|
163
|
+
logClose('warn', 'WS_SERVER_CLOSE', `Server closed socket: ${reason}`, {
|
|
164
|
+
reason: String(reason).slice(0, 48), closeCode: Number(code) || undefined, ...countDrop(),
|
|
165
|
+
});
|
|
27
166
|
try { ws.close(code, reason); } catch (_) {}
|
|
28
167
|
}
|
|
29
168
|
|
|
@@ -51,25 +190,55 @@ function bindWs(ws, deps) {
|
|
|
51
190
|
try { msg = JSON.parse(data.toString()); } catch (_) { return fail(1002, 'bad handshake'); }
|
|
52
191
|
if (msg.type !== 'attach') return fail(1002, 'expected attach');
|
|
53
192
|
if (!verifyToken(msg.token)) return fail(4401, 'bad token');
|
|
54
|
-
|
|
193
|
+
|
|
194
|
+
// Authorization precedes every live-PTY resume. A terminated record is
|
|
195
|
+
// different: its only remaining purpose is to deliver the late exit
|
|
196
|
+
// outcome, so it has an explicit, non-attach path below.
|
|
197
|
+
if (!isValidSession(msg.session)) {
|
|
198
|
+
const ended = ptyGrace && ptyGrace.resumeEnded(msg.reconnectToken, msg.session);
|
|
199
|
+
if (ended) {
|
|
200
|
+
clearAttachTimer();
|
|
201
|
+
sendJson(ws, { type: 'exit', code: ended.exitCode });
|
|
202
|
+
try { ws.close(1000, 'exit'); } catch (_) {}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
return fail(4404, 'no such session');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The capability is bound to the authenticated session and can resume
|
|
209
|
+
// only a still-live PTY. It is never an alternative to isValidSession.
|
|
210
|
+
const resumed = ptyGrace && ptyGrace.resume(msg.reconnectToken, msg.session);
|
|
211
|
+
if (resumed) {
|
|
212
|
+
clearAttachTimer();
|
|
213
|
+
record = resumed;
|
|
214
|
+
record.ws = ws;
|
|
215
|
+
record.readonly = record.readonly || !!msg.readonly;
|
|
216
|
+
attached = true;
|
|
217
|
+
session = record.session;
|
|
218
|
+
onAttach(session, ws);
|
|
219
|
+
logReattach();
|
|
220
|
+
sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
55
223
|
attached = true;
|
|
56
224
|
clearAttachTimer();
|
|
57
225
|
session = msg.session;
|
|
58
|
-
|
|
226
|
+
logReattach();
|
|
59
227
|
// Resize default: when nobody else is attached, drive the session size so a
|
|
60
228
|
// small phone gets a usable (non-clipped) view and clean line editing. When a
|
|
61
229
|
// real terminal is already attached, default to ignore-size so we don't shrink
|
|
62
230
|
// its window. An explicit takeSize from the client always wins.
|
|
63
|
-
const takeSize = msg.takeSize !== undefined
|
|
64
|
-
? !!msg.takeSize
|
|
65
|
-
: countClients(msg.session) === 0;
|
|
66
231
|
// "Segue il focus": garantisce window-size latest sulla sessione (il
|
|
67
232
|
// client usato piu' di recente ne guida la geometria). Fire-and-forget.
|
|
68
233
|
try {
|
|
69
234
|
require('node:child_process').execFile(defaults.tmuxBin || 'tmux',
|
|
70
235
|
['set-option', '-t', `=${msg.session}:`, 'window-size', 'latest'], () => {});
|
|
71
236
|
} catch (_) {}
|
|
72
|
-
|
|
237
|
+
const takeSize = msg.takeSize !== undefined
|
|
238
|
+
? !!msg.takeSize
|
|
239
|
+
: countClients(msg.session) === 0;
|
|
240
|
+
record = {
|
|
241
|
+
pty: openAttach(msg.session, {
|
|
73
242
|
// readonlyDefault del server e' un PAVIMENTO, non un default: se il server
|
|
74
243
|
// e' READONLY nessun client puo' declassarlo (msg.readonly:false non deve
|
|
75
244
|
// vincere). Il client puo' solo AGGIUNGERE restrizione (attach read-only
|
|
@@ -80,34 +249,84 @@ function bindWs(ws, deps) {
|
|
|
80
249
|
cols: clamp(msg.cols, 20, 300, 80),
|
|
81
250
|
rows: clamp(msg.rows, 5, 120, 24),
|
|
82
251
|
tmuxBin: defaults.tmuxBin || 'tmux',
|
|
252
|
+
}),
|
|
253
|
+
ws,
|
|
254
|
+
session: msg.session,
|
|
255
|
+
readonly: defaults.readonlyDefault === true || !!msg.readonly,
|
|
256
|
+
takeSize,
|
|
257
|
+
ended: false,
|
|
258
|
+
exitCode: undefined,
|
|
259
|
+
resumeToken: null,
|
|
260
|
+
graceTimer: null,
|
|
261
|
+
graceExpired: false,
|
|
262
|
+
};
|
|
263
|
+
// The token is issued for this record, not for an arbitrary caller.
|
|
264
|
+
if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
|
|
265
|
+
onAttach(session, ws);
|
|
266
|
+
record.pty.onData((d) => {
|
|
267
|
+
if (!record.ws) return;
|
|
268
|
+
try { record.ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
|
|
269
|
+
if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
|
|
83
270
|
});
|
|
84
|
-
pty.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
271
|
+
record.pty.onExit((info) => {
|
|
272
|
+
record.ended = true;
|
|
273
|
+
record.exitCode = info && info.exitCode;
|
|
274
|
+
logClose('notice', 'PTY_EXIT', 'Sessione terminata dal PTY', {
|
|
275
|
+
exitCode: typeof record.exitCode === 'number' ? record.exitCode : undefined,
|
|
276
|
+
});
|
|
277
|
+
if (!record.ws) return;
|
|
278
|
+
sendJson(record.ws, { type: 'exit', code: record.exitCode });
|
|
279
|
+
try { record.ws.close(1000, 'exit'); } catch (_) {}
|
|
91
280
|
});
|
|
281
|
+
sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
|
|
92
282
|
return;
|
|
93
283
|
}
|
|
94
284
|
// dopo l'attach
|
|
95
|
-
if (isBinary) { pty.write(data); return; }
|
|
285
|
+
if (isBinary) { if (!record.readonly) record.pty.write(data); return; }
|
|
96
286
|
let msg;
|
|
97
287
|
try { msg = JSON.parse(data.toString()); } catch (_) { return; }
|
|
98
288
|
if (msg.type === 'attach') return fail(1002, 'already attached'); // no 2nd attach
|
|
99
|
-
if (msg.type === 'resize') pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
|
|
289
|
+
if (msg.type === 'resize') record.pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
|
|
100
290
|
// focus: il tile che prende il focus diventa size-owner (promote); perdendolo
|
|
101
291
|
// torna ignore-size (demote). Cosi' N deck/tile sulla stessa sessione NON si
|
|
102
292
|
// contendono la geometria: comanda solo chi ha il focus (§5b size policy).
|
|
103
|
-
else if (msg.type === 'focus') { if (msg.on) pty.promote(); else pty.demote(); }
|
|
104
|
-
else if (msg.type === 'input') pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
105
|
-
else if (msg.type === 'key') pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
293
|
+
else if (msg.type === 'focus') { if (msg.on) record.pty.promote(); else record.pty.demote(); }
|
|
294
|
+
else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
295
|
+
else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
106
296
|
else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
|
|
107
297
|
}
|
|
108
298
|
|
|
299
|
+
// Alla riconnessione la DURATA della caduta e' il gap close->reopen della
|
|
300
|
+
// stessa sessione. Ritorni con gap = notice (sempre visibili); un primo
|
|
301
|
+
// attach senza storia e' debug (solo verbose).
|
|
302
|
+
function logReattach() {
|
|
303
|
+
if (!diagnostics) return;
|
|
304
|
+
if (!dropCounter) return;
|
|
305
|
+
try {
|
|
306
|
+
const reopen = dropCounter.recordReopen(session);
|
|
307
|
+
if (reopen.gapMs != null) {
|
|
308
|
+
diagnostics.record('notice', 'ws', 'WS_REATTACHED', 'Riconnesso dopo una caduta', {
|
|
309
|
+
cell: session, gapMs: reopen.gapMs, drops: reopen.drops,
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
diagnostics.record('debug', 'ws', 'WS_ATTACHED', 'Attach completato', { cell: session });
|
|
313
|
+
}
|
|
314
|
+
} catch (_) {}
|
|
315
|
+
}
|
|
316
|
+
|
|
109
317
|
ws.on('message', onMessage);
|
|
110
|
-
ws.on('close', () => {
|
|
111
|
-
|
|
318
|
+
ws.on('close', (code) => {
|
|
319
|
+
clearAttachTimer(); detach();
|
|
320
|
+
if (closeLogged) return;
|
|
321
|
+
const closeCode = Number(code) || undefined;
|
|
322
|
+
if (ws.__ncCloseReason === 'heartbeat-timeout') {
|
|
323
|
+
logClose('warn', 'WS_HEARTBEAT_DROPPED', 'Heartbeat scaduto: connessione mezzo-aperta terminata', { closeCode, ...countDrop() });
|
|
324
|
+
} else if (closeCode === 1006) {
|
|
325
|
+
logClose('warn', 'WS_ABNORMAL_CLOSE', 'Chiusura senza handshake (drop TCP o terminate)', { closeCode, ...countDrop() });
|
|
326
|
+
} else {
|
|
327
|
+
logClose('notice', 'WS_CLIENT_CLOSE', 'Il client ha chiuso la connessione', { closeCode, ...countDrop() });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
ws.on('error', () => { clearAttachTimer(); detach(); });
|
|
112
331
|
}
|
|
113
|
-
module.exports = { bindWs, clamp };
|
|
332
|
+
module.exports = { bindWs, clamp, createPtyGraceStore, PTY_GRACE_MS };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Contatore di cadute WebSocket: trasforma «celle che vanno e vengono»
|
|
3
|
+
// in numeri — quante cadute nella finestra (default 10 minuti) e, alla
|
|
4
|
+
// riconnessione, quanto è durata la caduta (gap tra close e reopen).
|
|
5
|
+
// In memoria, zero I/O: i valori viaggiano nei meta dei log di bridge.js.
|
|
6
|
+
function createDropCounter({ windowMs = 10 * 60 * 1000, now = Date.now } = {}) {
|
|
7
|
+
const drops = []; // { ts, sessionId }
|
|
8
|
+
const lastClose = new Map(); // sessionId -> ts
|
|
9
|
+
const key = (s) => String(s == null ? '' : s);
|
|
10
|
+
function prune(ts) {
|
|
11
|
+
const cutoff = ts - windowMs;
|
|
12
|
+
while (drops.length && drops[0].ts < cutoff) drops.shift();
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
recordDrop(session, ts = now()) {
|
|
16
|
+
const k = key(session);
|
|
17
|
+
drops.push({ ts, sessionId: k });
|
|
18
|
+
if (k) lastClose.set(k, ts);
|
|
19
|
+
prune(ts);
|
|
20
|
+
return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
|
|
21
|
+
},
|
|
22
|
+
// Alla riconnessione: la durata della caduta e' il gap close->reopen.
|
|
23
|
+
// Consuma il last close: una connessione lunga e sana non e' «ritorno».
|
|
24
|
+
recordReopen(session, ts = now()) {
|
|
25
|
+
const k = key(session);
|
|
26
|
+
const last = lastClose.get(k);
|
|
27
|
+
lastClose.delete(k);
|
|
28
|
+
prune(ts);
|
|
29
|
+
return { gapMs: last == null ? null : Math.max(0, ts - last), drops: drops.length };
|
|
30
|
+
},
|
|
31
|
+
snapshot(ts = now()) {
|
|
32
|
+
prune(ts);
|
|
33
|
+
return { drops: drops.length, windowSeconds: Math.round(windowMs / 1000) };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
module.exports = { createDropCounter };
|
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ Five nouns carry almost everything:
|
|
|
23
23
|
- **Node** — one installation on one machine. Its identity is an opaque
|
|
24
24
|
`instanceId`; the human-readable name is a **label**, and two nodes may
|
|
25
25
|
legitimately carry the same one. **Address things by id, never by name.**
|
|
26
|
-
- **Cell** — one stable working identity (`
|
|
26
|
+
- **Cell** — one stable working identity (`Alpha`, `Beta`, …) bound to one
|
|
27
27
|
tmux session and one engine. A cell is not a process: it survives restarts of
|
|
28
28
|
the service, and stopping it does not end the work it was doing.
|
|
29
29
|
- **Engine** — what a cell runs: an AI CLI, a plain shell, a command in a
|