@mmmbuto/nexuscrew 0.8.46 → 0.8.48
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/CHANGELOG.md +86 -0
- package/frontend/dist/assets/{index-BAq6N1Md.css → index-43DFO1EH.css} +1 -1
- package/frontend/dist/assets/index-C_SyIZ78.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +18 -1
- package/lib/fleet/builtin.js +7 -1
- package/lib/fleet/cell-exec.js +126 -18
- package/lib/fleet/definitions.js +16 -0
- package/lib/fleet/launch.js +80 -23
- package/lib/fleet/managed.js +10 -2
- package/lib/fleet/prompt-delivery.js +275 -0
- package/lib/fleet/runtime.js +52 -11
- package/lib/mcp/cells.js +11 -0
- package/lib/mcp/tools.js +1 -1
- package/lib/nodes/inventory.js +5 -0
- package/lib/nodes/reverse-rotation.js +22 -1
- package/lib/nodes/reverse-slot-listeners.js +10 -1
- package/lib/nodes/reverse-slot-proof.js +10 -0
- package/lib/nodes/topology-cache.js +11 -2
- package/lib/proxy/federation.js +98 -10
- package/lib/server.js +27 -7
- package/lib/settings/routes.js +58 -5
- package/lib/ws/bridge.js +26 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DMG-rioF.js +0 -93
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Delivery del bootstrap prompt per gli engine managed Kimi (kimi.native e
|
|
3
|
+
// claude.kimi-code) — NexusCrew 0.8.47. SOLO questi due engine: gli altri
|
|
4
|
+
// managed conservano il prompt su argv (finding separato) e i custom send-keys
|
|
5
|
+
// conservano injectPrompt legacy senza Enter.
|
|
6
|
+
//
|
|
7
|
+
// Contratto (design audit 0.8.47):
|
|
8
|
+
// - Readiness REALE prima della consegna: il pane viene classificato sul SOLO
|
|
9
|
+
// viewport visibile (capture-pane -p, mai scrollback -S: su generation>0 lo
|
|
10
|
+
// scrollback puo' contenere vecchi prompt e marker stale). Auth, consenso
|
|
11
|
+
// custom API key e onboarding/trust sono NOT_READY: nessun paste, nessun
|
|
12
|
+
// Enter, la sessione resta viva e utilizzabile dall'operatore. UNKNOWN a
|
|
13
|
+
// timeout NON diventa mai READY: la consegna viene saltata.
|
|
14
|
+
// - AT-MOST-ONCE per generazione: UNA sola sequenza paste+Enter. Retry
|
|
15
|
+
// automatico ammesso SOLO su fallimento certo PRE-paste (resolve pane o
|
|
16
|
+
// load-buffer falliti): mai un secondo paste — dopo QUALUNQUE tentativo di
|
|
17
|
+
// paste-buffer il composer potrebbe contenere testo parziale/completo e lo
|
|
18
|
+
// stato e' DELIVERY_UNKNOWN/STAGED_NOT_SUBMITTED con zero secondo paste.
|
|
19
|
+
// Dopo un Enter OK l'esito e' 'submitted' e non esiste replay.
|
|
20
|
+
// - Classifier enum-only: il testo catturato non viene MAI loggato,
|
|
21
|
+
// persistito o incluso in risposte; esce solo lo stato bounded.
|
|
22
|
+
// - Recovery da catalogo costante; mai suggerire /login Anthropic per
|
|
23
|
+
// claude.kimi-code (il recovery corretto e' il consenso Kimi in /config).
|
|
24
|
+
const { tmuxExec, securePaste, resolveSessionPane, promptCharsOk, sleep } = require('./launch.js');
|
|
25
|
+
|
|
26
|
+
// Stati bounded del classifier pane. Output unico esposto all'esterno.
|
|
27
|
+
const PANE_STATES = Object.freeze([
|
|
28
|
+
'ready', 'busy', 'not-ready-auth', 'not-ready-consent', 'not-ready-onboarding', 'unknown',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Esiti bounded della consegna (prompt.reason in API: closed enum, G5).
|
|
32
|
+
const DELIVERY_STATES = Object.freeze([
|
|
33
|
+
'submitted', // paste ok + pane riverificato + Enter ok
|
|
34
|
+
'staged-not-submitted', // paste ok, Enter fallito: testo forse nel composer
|
|
35
|
+
'delivery-unknown', // paste tentato con esito incerto / pane sparito dopo il paste
|
|
36
|
+
'failed-pre-paste', // resolve/load certamente falliti (anche dopo 1 retry)
|
|
37
|
+
'skipped-not-ready', // classifier not-ready a fine attesa bounded (kind in notReady)
|
|
38
|
+
'skipped-unknown', // classifier mai ready entro il timeout
|
|
39
|
+
'prompt-rejected', // byte di controllo nel prompt (policy §9e)
|
|
40
|
+
'cancelled', // generazione terminata durante l'attesa/consegna (R3)
|
|
41
|
+
'report-timeout', // up() non ha ricevuto l'esito del launcher entro il bound
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// Codici bounded actionRequired (G5). Nessuna inferenza "rifiutato": il marker
|
|
45
|
+
// TUI prova solo che serve un'azione nel terminale della cella.
|
|
46
|
+
const ACTION_CODES = Object.freeze(['KIMI_AUTH_ACTION_REQUIRED', 'CLIENT_INTERACTION_REQUIRED']);
|
|
47
|
+
|
|
48
|
+
// Slug recovery bounded (R10): l'API trasporta SOLO {code, recovery}; il testo
|
|
49
|
+
// e' mappato localmente dalla PWA via i18n (fleet-recovery-<slug>), mai inviato
|
|
50
|
+
// dal server (un nodo remoto federato non deve poter iniettare testo libero).
|
|
51
|
+
const RECOVERY_SLUGS = Object.freeze([
|
|
52
|
+
'kimi-code-consent-yes',
|
|
53
|
+
'kimi-code-config-custom-api-key',
|
|
54
|
+
'kimi-cli-login',
|
|
55
|
+
'client-terminal-dialog',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Marker di dialoghi MODALI full-screen: occupano la viewport, valgono su
|
|
59
|
+
// tutto il catturato visibile (non solo la coda).
|
|
60
|
+
// Live-verificati: claude 2.1.220 (consent custom API key, trust dialog).
|
|
61
|
+
const MODAL_NOT_READY = Object.freeze([
|
|
62
|
+
['not-ready-consent', /Detected a custom API key in your environment|Do you want to use this API key\?/],
|
|
63
|
+
['not-ready-onboarding', /Yes, I trust this folder|Quick safety check/],
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Marker ancorati alla CODA visibile (status bar / righe finali): lo stesso
|
|
67
|
+
// testo in scrollback, nel corpo conversazione o in forma diversa non deve
|
|
68
|
+
// classificare (R7: il marker auth e' la stringa ESATTA di status bar, non un
|
|
69
|
+
// "Not logged in" qualunque). Live-verificati: claude 2.1.220 (status bar
|
|
70
|
+
// "Not logged in · Run /login"), kimi 0.31.1 (welcome "Run /login or
|
|
71
|
+
// /provider", "Model: not set").
|
|
72
|
+
const TAIL_NOT_READY = Object.freeze([
|
|
73
|
+
['not-ready-auth', /Not logged in · (?:Please run|Run) \/login\b/],
|
|
74
|
+
['not-ready-auth', /Run \/login or \/provider|Model:\s+not set\b/],
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// TUI occupata a processare (restart durante lavoro residuo): si attende.
|
|
78
|
+
const BUSY_TAIL = /esc to interrupt/i;
|
|
79
|
+
|
|
80
|
+
const TAIL_LINES = 14;
|
|
81
|
+
|
|
82
|
+
function tailOf(text) {
|
|
83
|
+
const lines = String(text).split('\n');
|
|
84
|
+
const nonEmpty = [];
|
|
85
|
+
for (let i = lines.length - 1; i >= 0 && nonEmpty.length < TAIL_LINES; i -= 1) {
|
|
86
|
+
if (lines[i].trim() !== '') nonEmpty.unshift(lines[i]);
|
|
87
|
+
}
|
|
88
|
+
return nonEmpty.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// classifyPane(captured, client) -> PANE_STATES enum. Pura, senza dipendenze:
|
|
92
|
+
// il testo resta dentro la funzione, fuori esce solo lo stato bounded.
|
|
93
|
+
// client: 'kimi' | 'claude' (gli altri non passano mai di qui: 'unknown').
|
|
94
|
+
function classifyPane(captured, client) {
|
|
95
|
+
const text = typeof captured === 'string' ? captured : '';
|
|
96
|
+
if (!text.trim()) return 'unknown';
|
|
97
|
+
for (const [state, re] of MODAL_NOT_READY) if (re.test(text)) return state;
|
|
98
|
+
const tail = tailOf(text);
|
|
99
|
+
for (const [state, re] of TAIL_NOT_READY) if (re.test(tail)) return state;
|
|
100
|
+
if (BUSY_TAIL.test(tail)) return 'busy';
|
|
101
|
+
if (client === 'kimi') {
|
|
102
|
+
// READY positivo Kimi: box di input corrente E modello configurato (G2).
|
|
103
|
+
// Il solo "Model:" non basta; la sola box non basta (presente anche da
|
|
104
|
+
// logged-out). Il welcome logged-out e' gia' intercettato dai marker
|
|
105
|
+
// not-ready qui sopra; se scrolla via senza login resta 'unknown' (safe).
|
|
106
|
+
const box = /^\s*│\s*>/m.test(text);
|
|
107
|
+
const model = /! to run a shell command/.test(tail) || /Model:\s+(?!not set\b)\S/.test(text);
|
|
108
|
+
return box && model ? 'ready' : 'unknown';
|
|
109
|
+
}
|
|
110
|
+
if (client === 'claude') {
|
|
111
|
+
// READY positivo Claude: riga prompt "❯" in coda, assenza dei marker
|
|
112
|
+
// not-ready (gia' valutati sopra: il cursore ❯ dei dialoghi non inganna).
|
|
113
|
+
return /^\s*❯/m.test(tail) ? 'ready' : 'unknown';
|
|
114
|
+
}
|
|
115
|
+
return 'unknown';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clampInt(value, dflt, min, max) {
|
|
119
|
+
const n = Number(value);
|
|
120
|
+
if (!Number.isFinite(n)) return dflt;
|
|
121
|
+
return Math.max(min, Math.min(max, Math.trunc(n)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// deliverBootstrapPrompt(opts) -> { delivered, state, notReady, attempts, reason }
|
|
125
|
+
// opts: { tmuxBin, session, prompt, client ('kimi'|'claude'), env,
|
|
126
|
+
// paneTarget (%N opzionale), readyWaitMs, pollMs, settleMs, tmpdir,
|
|
127
|
+
// tmuxExecImpl, captureImpl, sleepImpl, nowImpl, fsImpl, isCancelled }
|
|
128
|
+
// reason e' SEMPRE lo state (closed enum); notReady e' il kind bounded o ''.
|
|
129
|
+
// isCancelled() (R3): valutato ad OGNI poll, prima del paste e prima dell'
|
|
130
|
+
// Enter; se true la consegna si ferma subito ('cancelled') senza paste/Enter —
|
|
131
|
+
// cell-exec cancella la delivery quando la generazione termina, cosi' un
|
|
132
|
+
// polling in volo non puo' mai iniettare nella generazione successiva.
|
|
133
|
+
async function deliverBootstrapPrompt(opts = {}) {
|
|
134
|
+
const { tmuxBin, session, prompt } = opts;
|
|
135
|
+
const client = typeof opts.client === 'string' ? opts.client : '';
|
|
136
|
+
const env = opts.env;
|
|
137
|
+
const exec = opts.tmuxExecImpl || tmuxExec;
|
|
138
|
+
const sleepImpl = opts.sleepImpl || sleep;
|
|
139
|
+
const now = opts.nowImpl || Date.now;
|
|
140
|
+
const cancelled = typeof opts.isCancelled === 'function' ? opts.isCancelled : () => false;
|
|
141
|
+
const target = opts.paneTarget || `=${session}`;
|
|
142
|
+
const readyWaitMs = clampInt(opts.readyWaitMs, 15000, 0, 120000);
|
|
143
|
+
const pollMs = clampInt(opts.pollMs, 400, 50, 5000);
|
|
144
|
+
const settleMs = clampInt(opts.settleMs, 150, 0, 5000);
|
|
145
|
+
const done = (delivered, state, notReady, attempts) => ({
|
|
146
|
+
delivered, state, notReady, attempts, reason: state,
|
|
147
|
+
});
|
|
148
|
+
if (!promptCharsOk(prompt)) return done(false, 'prompt-rejected', '', 0);
|
|
149
|
+
|
|
150
|
+
const capture = opts.captureImpl || (async () => {
|
|
151
|
+
const r = await exec(tmuxBin, ['capture-pane', '-p', '-t', target], { env, timeoutMs: 2000 });
|
|
152
|
+
return r.err ? null : r.stdout;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// (1) Attesa readiness bounded: solo 'ready' positivo sblocca la consegna.
|
|
156
|
+
let paneState = 'unknown';
|
|
157
|
+
const deadline = now() + readyWaitMs;
|
|
158
|
+
for (;;) {
|
|
159
|
+
if (cancelled()) return done(false, 'cancelled', '', 0);
|
|
160
|
+
const text = await capture();
|
|
161
|
+
if (cancelled()) return done(false, 'cancelled', '', 0);
|
|
162
|
+
paneState = text === null ? 'unknown' : classifyPane(text, client);
|
|
163
|
+
if (paneState === 'ready') break;
|
|
164
|
+
if (now() >= deadline) {
|
|
165
|
+
return paneState === 'unknown'
|
|
166
|
+
? done(false, 'skipped-unknown', '', 0)
|
|
167
|
+
: done(false, 'skipped-not-ready', paneState, 0);
|
|
168
|
+
}
|
|
169
|
+
await sleepImpl(pollMs);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// (2) Consegna AT-MOST-ONCE: un solo paste per generazione. Il retry copre
|
|
173
|
+
// SOLO fallimenti certi pre-paste (resolve/load); dopo qualsiasi tentativo
|
|
174
|
+
// di paste-buffer non esiste retry automatico (G1).
|
|
175
|
+
let attempts = 0;
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (cancelled()) return done(false, 'cancelled', '', attempts);
|
|
178
|
+
attempts += 1;
|
|
179
|
+
const stage = await securePaste(tmuxBin, session, prompt, {
|
|
180
|
+
env, exec, target, tmpdir: opts.tmpdir, fsImpl: opts.fsImpl,
|
|
181
|
+
});
|
|
182
|
+
if (cancelled()) {
|
|
183
|
+
// Il paste potrebbe essere avvenuto: mai riprovare, mai Enter.
|
|
184
|
+
return done(false, stage.ok ? 'delivery-unknown' : 'cancelled', '', attempts);
|
|
185
|
+
}
|
|
186
|
+
if (stage.ok) {
|
|
187
|
+
await sleepImpl(settleMs);
|
|
188
|
+
if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
|
|
189
|
+
// Riverifica dello stesso %N sulla stessa sessione prima dell'Enter.
|
|
190
|
+
const paneAgain = await resolveSessionPane(tmuxBin, session, { env, exec, target });
|
|
191
|
+
if (paneAgain !== stage.paneId) return done(false, 'delivery-unknown', '', attempts);
|
|
192
|
+
if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
|
|
193
|
+
const enter = await exec(tmuxBin, ['send-keys', '-t', stage.paneId, 'Enter'], { env });
|
|
194
|
+
if (enter.err) return done(false, 'staged-not-submitted', '', attempts);
|
|
195
|
+
// R9: cancel con Enter appena partito -> la generazione e' morta con un
|
|
196
|
+
// submit in volo: esito incerto (residuo PTY possibile), mai 'submitted'.
|
|
197
|
+
if (cancelled()) return done(false, 'delivery-unknown', '', attempts);
|
|
198
|
+
return done(true, 'submitted', '', attempts);
|
|
199
|
+
}
|
|
200
|
+
if (stage.stage === 'paste' || stage.stage === 'validate') {
|
|
201
|
+
// Paste tentato (esito incerto) o input rifiutato: zero secondo paste.
|
|
202
|
+
return done(false, stage.stage === 'validate' ? 'prompt-rejected' : 'delivery-unknown', '', attempts);
|
|
203
|
+
}
|
|
204
|
+
if (attempts >= 2) return done(false, 'failed-pre-paste', '', attempts);
|
|
205
|
+
// Fallimento certo PRE-paste: un solo retry, readiness riverificata prima.
|
|
206
|
+
await sleepImpl(pollMs);
|
|
207
|
+
if (cancelled()) return done(false, 'cancelled', '', attempts);
|
|
208
|
+
const text = await capture();
|
|
209
|
+
const again = text === null ? 'unknown' : classifyPane(text, client);
|
|
210
|
+
if (again !== 'ready') return done(false, 'failed-pre-paste', '', attempts);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// waitDeliveryReport(tmuxBin, target, opts) -> delivery-like | null (R2).
|
|
215
|
+
// Il launcher supervisionato (cell-exec) e' l'UNICO owner della consegna per
|
|
216
|
+
// TUTTE le generazioni degli engine Kimi e pubblica l'esito bounded sull'
|
|
217
|
+
// opzione tmux di pane @nc_delivery ('<state>' o '<state>:<notReady>', solo
|
|
218
|
+
// enum chiusi; muore col pane, nessuno state file). up() la legge con attesa
|
|
219
|
+
// bounded: niente paste dal runtime, niente doppio writer cross-generation.
|
|
220
|
+
async function waitDeliveryReport(tmuxBin, target, { env, exec, sleepImpl, nowImpl, timeoutMs, pollMs } = {}) {
|
|
221
|
+
const run = exec || tmuxExec;
|
|
222
|
+
const sleepFn = sleepImpl || sleep;
|
|
223
|
+
const now = nowImpl || Date.now;
|
|
224
|
+
const deadline = now() + clampInt(timeoutMs, 18000, 100, 150000);
|
|
225
|
+
const step = clampInt(pollMs, 300, 50, 5000);
|
|
226
|
+
for (;;) {
|
|
227
|
+
const r = await run(tmuxBin,
|
|
228
|
+
['display-message', '-p', '-t', target, '#{@nc_delivery}'],
|
|
229
|
+
{ env, timeoutMs: 2000 });
|
|
230
|
+
const raw = r.err ? '' : r.stdout.trim();
|
|
231
|
+
if (raw) {
|
|
232
|
+
const [state, notReady = ''] = raw.split(':');
|
|
233
|
+
if (!DELIVERY_STATES.includes(state)) return null; // valore non bounded: mai fidarsi
|
|
234
|
+
const kind = PANE_STATES.includes(notReady) ? notReady : '';
|
|
235
|
+
return {
|
|
236
|
+
delivered: state === 'submitted',
|
|
237
|
+
state, notReady: state === 'skipped-not-ready' ? kind : '',
|
|
238
|
+
attempts: 0, reason: state,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (now() >= deadline) return null;
|
|
242
|
+
await sleepFn(step);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// actionRequiredFor(client, provider, delivery) -> null | { code, recovery }
|
|
247
|
+
// Mappa bounded delivery -> azione operatore. Solo skip per not-ready/unknown
|
|
248
|
+
// producono actionRequired; i fallimenti di trasporto restano nel prompt.state.
|
|
249
|
+
// R10: SOLO {code, recovery} closed enum/slug — il testo e' i18n locale PWA.
|
|
250
|
+
function actionRequiredFor(client, provider, delivery) {
|
|
251
|
+
if (!delivery || delivery.delivered) return null;
|
|
252
|
+
if (delivery.state !== 'skipped-not-ready' && delivery.state !== 'skipped-unknown') return null;
|
|
253
|
+
const kind = delivery.notReady;
|
|
254
|
+
let recovery = 'client-terminal-dialog';
|
|
255
|
+
let code = 'CLIENT_INTERACTION_REQUIRED';
|
|
256
|
+
if (provider === 'kimi-code') {
|
|
257
|
+
code = kind === 'not-ready-consent' || kind === 'not-ready-auth'
|
|
258
|
+
? 'KIMI_AUTH_ACTION_REQUIRED' : 'CLIENT_INTERACTION_REQUIRED';
|
|
259
|
+
recovery = kind === 'not-ready-consent' ? 'kimi-code-consent-yes'
|
|
260
|
+
: kind === 'not-ready-auth' ? 'kimi-code-config-custom-api-key'
|
|
261
|
+
: 'client-terminal-dialog';
|
|
262
|
+
} else if (client === 'kimi') {
|
|
263
|
+
if (kind === 'not-ready-auth') {
|
|
264
|
+
code = 'KIMI_AUTH_ACTION_REQUIRED';
|
|
265
|
+
recovery = 'kimi-cli-login';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!ACTION_CODES.includes(code) || !RECOVERY_SLUGS.includes(recovery)) return null;
|
|
269
|
+
return { code, recovery };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
PANE_STATES, DELIVERY_STATES, ACTION_CODES, RECOVERY_SLUGS,
|
|
274
|
+
classifyPane, deliverBootstrapPrompt, waitDeliveryReport, actionRequiredFor,
|
|
275
|
+
};
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
waitAlive, waitStablePane, injectPrompt,
|
|
26
26
|
redactSecrets, sanitizeEarlyDiagnostic,
|
|
27
27
|
} = require('./launch.js');
|
|
28
|
+
const { waitDeliveryReport, actionRequiredFor } = require('./prompt-delivery.js');
|
|
28
29
|
|
|
29
30
|
// TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
|
|
30
31
|
const STATUS_TTL_MS = 2000;
|
|
@@ -92,7 +93,9 @@ function createBuiltinRuntime(ctx) {
|
|
|
92
93
|
? 'standard'
|
|
93
94
|
: (remembered || engineDefault || '');
|
|
94
95
|
return {
|
|
95
|
-
cell
|
|
96
|
+
// `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
|
|
97
|
+
// leggibile e viaggia accanto, senza mai sostituirlo.
|
|
98
|
+
cell: c.id, label: c.label || '', tmuxSession: c.tmuxSession, engine: c.engine,
|
|
96
99
|
model: c.model || '', models: { ...(c.models || {}) },
|
|
97
100
|
permissionPolicy: effectivePolicy,
|
|
98
101
|
permissionPolicies: { ...(c.permissionPolicies || {}) },
|
|
@@ -209,6 +212,12 @@ function createBuiltinRuntime(ctx) {
|
|
|
209
212
|
tmuxSession: cell.tmuxSession,
|
|
210
213
|
prompt: cell.prompt,
|
|
211
214
|
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
215
|
+
// Solo gli engine managed Kimi (kimi.native / claude.kimi-code) hanno
|
|
216
|
+
// la delivery classificata at-most-once ai restart; gli engine custom
|
|
217
|
+
// send-keys conservano il reinject legacy senza Enter (0.8.47 G4).
|
|
218
|
+
client: engine.managed && engine.managed.client === 'kimi' ? 'kimi'
|
|
219
|
+
: (engine.managed && engine.managed.provider === 'kimi-code' ? 'claude' : ''),
|
|
220
|
+
readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
|
|
212
221
|
},
|
|
213
222
|
} : {}),
|
|
214
223
|
});
|
|
@@ -396,20 +405,52 @@ function createBuiltinRuntime(ctx) {
|
|
|
396
405
|
};
|
|
397
406
|
}
|
|
398
407
|
|
|
399
|
-
// (6) prompt
|
|
400
|
-
//
|
|
408
|
+
// (6) prompt: due percorsi distinti (0.8.47, R2 single-owner).
|
|
409
|
+
// - managed kimi.native / claude.kimi-code: la consegna e' posseduta SOLO
|
|
410
|
+
// dal supervisore (cell-exec) per TUTTE le generazioni; qui si legge
|
|
411
|
+
// l'esito bounded (@nc_delivery sul pane) con attesa bounded. Mai paste/
|
|
412
|
+
// Enter dal runtime: niente doppia delivery se gen0 muore durante
|
|
413
|
+
// l'attesa e gen1 parte sotto il supervisore.
|
|
414
|
+
// - engine custom promptMode 'send-keys': injectPrompt legacy (bracketed
|
|
415
|
+
// paste SENZA Enter, contratto invariato — G4), target %N esatto.
|
|
401
416
|
let prompt = null;
|
|
417
|
+
let actionRequired = null;
|
|
402
418
|
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
419
|
+
const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
|
|
420
|
+
const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
|
|
421
|
+
const classified = managedClient === 'kimi' || managedProvider === 'kimi-code';
|
|
422
|
+
if (classified) {
|
|
423
|
+
const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
|
|
424
|
+
const reportWaitMs = Math.max(100, Math.min(150000,
|
|
425
|
+
(Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
|
|
426
|
+
const report = await waitDeliveryReport(tmuxBin,
|
|
427
|
+
paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
|
|
428
|
+
{ env: minimalEnv(), timeoutMs: reportWaitMs });
|
|
429
|
+
const delivery = report || {
|
|
430
|
+
delivered: false, state: 'report-timeout', notReady: '', attempts: 0, reason: 'report-timeout',
|
|
431
|
+
};
|
|
432
|
+
prompt = {
|
|
433
|
+
injected: delivery.delivered,
|
|
434
|
+
delivered: delivery.delivered,
|
|
435
|
+
state: delivery.state,
|
|
436
|
+
reason: delivery.reason,
|
|
437
|
+
};
|
|
438
|
+
actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
|
|
439
|
+
} else {
|
|
440
|
+
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
441
|
+
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
442
|
+
env: minimalEnv(),
|
|
443
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
|
|
444
|
+
target,
|
|
445
|
+
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
410
448
|
}
|
|
411
449
|
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
412
|
-
return {
|
|
450
|
+
return {
|
|
451
|
+
ok: true, cell: cellId, session: cell.tmuxSession, prompt,
|
|
452
|
+
...(actionRequired ? { actionRequired } : {}),
|
|
453
|
+
};
|
|
413
454
|
}
|
|
414
455
|
|
|
415
456
|
async function down(cellId /* , opts */) {
|
package/lib/mcp/cells.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
|
|
9
9
|
// owner-qualified sono applicate lato server HTTP.
|
|
10
10
|
const { isValidSession } = require('../files/store.js');
|
|
11
|
+
// Stesso validatore usato in uscita: una label federata non deve poter entrare
|
|
12
|
+
// qui in una forma che noi non produrremmo mai.
|
|
13
|
+
const { safeCellLabel } = require('../cells/routes.js');
|
|
11
14
|
|
|
12
15
|
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
13
16
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
@@ -109,6 +112,14 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
|
109
112
|
owner: owner.label,
|
|
110
113
|
route,
|
|
111
114
|
cell: raw.cell,
|
|
115
|
+
// `cell` indirizza, `label` si legge. Un nodo che chiama la propria cella
|
|
116
|
+
// come il motore la esporrebbe cosi' a tutta la rete: senza un nome
|
|
117
|
+
// separato chi la riceve non ha modo di sapere che ruolo occupa.
|
|
118
|
+
// La label di una cella REMOTA e' testo auto-dichiarato: si delimita con
|
|
119
|
+
// lo stesso validatore usato in uscita, e si marca come riferita perche'
|
|
120
|
+
// chi legge distingua cio' che sappiamo da cio' che ci e' stato detto.
|
|
121
|
+
label: safeCellLabel(raw.label),
|
|
122
|
+
labelReported: owner.route.length > 0,
|
|
112
123
|
tmuxSession: raw.tmuxSession,
|
|
113
124
|
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
114
125
|
model: typeof raw.model === 'string' ? raw.model : '',
|
package/lib/mcp/tools.js
CHANGED
|
@@ -260,7 +260,7 @@ const TOOLS = [
|
|
|
260
260
|
if (f && f.available) {
|
|
261
261
|
fleet = {
|
|
262
262
|
cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
|
|
263
|
-
cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
|
|
263
|
+
cell: c.cell, label: c.label || '', session: c.tmuxSession, engine: c.engine, active: !!c.active,
|
|
264
264
|
})),
|
|
265
265
|
};
|
|
266
266
|
}
|
package/lib/nodes/inventory.js
CHANGED
|
@@ -42,6 +42,11 @@ function routedPeer(entry) {
|
|
|
42
42
|
const route = Array.isArray(entry.route) ? [...entry.route] : [];
|
|
43
43
|
return {
|
|
44
44
|
name: entry.name,
|
|
45
|
+
// Riferita dal nodo che ce l'ha inoltrata, non verificata da noi: si mostra
|
|
46
|
+
// perche' senza di essa un nodo in transito e' solo uno slug, ma resta un
|
|
47
|
+
// dato di seconda mano e non sostituisce l'instanceId.
|
|
48
|
+
label: typeof entry.label === 'string' ? entry.label : '',
|
|
49
|
+
labelReported: true,
|
|
45
50
|
nodeId: entry.instanceId,
|
|
46
51
|
instanceId: entry.instanceId,
|
|
47
52
|
kind: 'transitive',
|
|
@@ -75,4 +75,25 @@ function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
|
|
|
75
75
|
return next;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
// Esito di uno spegnimento che puo' riguardare PIU' entry insieme: durante la
|
|
80
|
+
// grace di una rotazione lo slot vecchio e quello nuovo coesistono. Una
|
|
81
|
+
// chiusura riuscita su una sola non e' una chiusura: dichiararla tale
|
|
82
|
+
// lascerebbe l'altra viva mentre chi chiama registra il canale come privato.
|
|
83
|
+
// `no pidfile` e `stale (pid dead)` contano come spente: li' non e' rimasto
|
|
84
|
+
// nulla di vivo da attribuire.
|
|
85
|
+
const STOP_ALREADY_GONE = ['no pidfile', 'stale (pid dead)'];
|
|
86
|
+
|
|
87
|
+
function stopWasDemonstrated(result) {
|
|
88
|
+
if (!result) return false;
|
|
89
|
+
return result.stopped === true || STOP_ALREADY_GONE.includes(result.reason);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function summarizeStops(results) {
|
|
93
|
+
const list = Array.isArray(results) ? results : [];
|
|
94
|
+
const stoppedAny = list.some(stopWasDemonstrated);
|
|
95
|
+
const quarantinedAny = list.some((result) => !stopWasDemonstrated(result));
|
|
96
|
+
return { stoppedAny, quarantinedAny, allClosed: stoppedAny && !quarantinedAny };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot, stopWasDemonstrated, summarizeStops };
|
|
@@ -23,8 +23,16 @@ function close(server) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
|
|
26
|
+
function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics, attachUpgrade } = {}) {
|
|
27
27
|
if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
|
|
28
|
+
// Fail-closed: servire `app` non basta: il routing degli upgrade WS vive
|
|
29
|
+
// sull'istanza `server`, non sull'app Express. Un listener senza handler di
|
|
30
|
+
// upgrade e' HTTP-only e degrada in silenzio (la SPA risponde 200 a un
|
|
31
|
+
// upgrade). Obbligare la dipendenza rende impossibile ricreare il difetto
|
|
32
|
+
// per dimenticanza, invece di affidarsi a un test che enumera gli ingressi.
|
|
33
|
+
if (typeof attachUpgrade !== 'function') {
|
|
34
|
+
throw new Error('reverse slot listeners richiede attachUpgrade');
|
|
35
|
+
}
|
|
28
36
|
const listeners = new Map(); // local target port -> owned server + immutable expected tuple
|
|
29
37
|
|
|
30
38
|
async function open({ nodeName, remotePort, generation, instanceId, secret }) {
|
|
@@ -33,6 +41,7 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
|
|
|
33
41
|
}
|
|
34
42
|
if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
|
|
35
43
|
const server = createServerImpl(app);
|
|
44
|
+
attachUpgrade(server);
|
|
36
45
|
let address;
|
|
37
46
|
try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
|
|
38
47
|
catch (error) { try { server.close(); } catch (_) {} throw error; }
|
|
@@ -96,6 +96,16 @@ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, tim
|
|
|
96
96
|
const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
|
|
97
97
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
|
|
98
98
|
});
|
|
99
|
+
// Un 409 e' la RISPOSTA di un listener corretto che ha valutato la tupla e
|
|
100
|
+
// l'ha rifiutata: e' un esito, non un'assenza. Collassarlo in
|
|
101
|
+
// "non ottenuta" lo renderebbe indistinguibile da un timeout, e chi
|
|
102
|
+
// classifica gli errori tratterebbe come transitorio un mismatch reale.
|
|
103
|
+
if (response && response.status === 409) {
|
|
104
|
+
const failure = await response.json().catch(() => null);
|
|
105
|
+
const code = failure && typeof failure.code === 'string' && /^[a-z0-9-]{1,64}$/.test(failure.code)
|
|
106
|
+
? failure.code : 'reverse-slot-proof-mismatch';
|
|
107
|
+
return { owned: false, code };
|
|
108
|
+
}
|
|
99
109
|
if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
|
|
100
110
|
const body = await response.json().catch(() => null);
|
|
101
111
|
return verifySlotProof({ secret, expected, challenge: probe, response: body });
|
|
@@ -17,13 +17,22 @@ function defaultPath(home = os.homedir()) {
|
|
|
17
17
|
function parseEntry(raw) {
|
|
18
18
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
19
|
const keys = Object.keys(raw).sort();
|
|
20
|
-
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
|
|
20
|
+
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route', 'label'].includes(k))) return null;
|
|
21
21
|
if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
|
|
22
22
|
if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
|
|
23
23
|
if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
|
|
24
24
|
if (raw.name !== raw.route[raw.route.length - 1]) return null;
|
|
25
25
|
if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
|
|
26
|
-
|
|
26
|
+
// La label persiste solo se rispetta la stessa forma accettata altrove: un
|
|
27
|
+
// file di cache e' un ingresso come un altro e non deve poter reintrodurre
|
|
28
|
+
// testo che non produrremmo mai.
|
|
29
|
+
const label = typeof raw.label === 'string' && raw.label.trim()
|
|
30
|
+
&& raw.label.trim().length <= 64 && !/[\x00-\x1f\x7f]/.test(raw.label.trim())
|
|
31
|
+
? raw.label.trim() : null;
|
|
32
|
+
return {
|
|
33
|
+
instanceId: raw.instanceId, name: raw.name, route: [...raw.route],
|
|
34
|
+
...(label ? { label } : {}), lastSeen: raw.lastSeen,
|
|
35
|
+
};
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
function parseCache(raw) {
|