@mmmbuto/nexuscrew 0.8.46 → 0.8.47
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 +24 -0
- package/frontend/dist/assets/{index-DMG-rioF.js → index-Db2ivuxA.js} +28 -28
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/cell-exec.js +126 -18
- 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 +49 -10
- package/package.json +1 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-Db2ivuxA.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-BAq6N1Md.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.47"}
|
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -65,12 +65,14 @@ function promptCharsOk(prompt) {
|
|
|
65
65
|
function validRestartPrompt(value) {
|
|
66
66
|
if (value === undefined) return true;
|
|
67
67
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
68
|
-
if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs'].includes(key))) return false;
|
|
68
|
+
if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs'].includes(key))) return false;
|
|
69
69
|
return typeof value.tmuxBin === 'string' && value.tmuxBin.length > 0 && value.tmuxBin.length <= 4096
|
|
70
70
|
&& !/[\0\r\n]/.test(value.tmuxBin)
|
|
71
71
|
&& typeof value.tmuxSession === 'string' && /^[\w.@%:+-]{1,128}$/.test(value.tmuxSession)
|
|
72
72
|
&& promptCharsOk(value.prompt)
|
|
73
|
-
&& (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000))
|
|
73
|
+
&& (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000))
|
|
74
|
+
&& (value.client === undefined || value.client === '' || value.client === 'kimi' || value.client === 'claude')
|
|
75
|
+
&& (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000));
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function validPayload(payload) {
|
|
@@ -138,24 +140,117 @@ function waitChild(child) {
|
|
|
138
140
|
});
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// Delivery del bootstrap prompt per generazione (0.8.47, R2/R3).
|
|
144
|
+
//
|
|
145
|
+
// OWNER UNICO: per gli engine managed Kimi (client 'kimi'/'claude') il
|
|
146
|
+
// supervisore consegna il prompt per TUTTE le generazioni, gen0 compresa —
|
|
147
|
+
// il runtime non esegue MAI paste/Enter per questi engine (legge solo l'esito
|
|
148
|
+
// bounded via opzione di pane @nc_delivery). Cosi' non esiste doppio writer
|
|
149
|
+
// cross-generation: se gen0 muore durante l'attesa readiness, la sua delivery
|
|
150
|
+
// viene cancellata e attesa PRIMA dello spawn di gen1, che ricevera' al
|
|
151
|
+
// massimo una nuova consegna. Engine custom send-keys: contratto legacy
|
|
152
|
+
// invariato (gen0 = runtime.injectPrompt; qui solo gen>0, paste senza Enter).
|
|
153
|
+
//
|
|
154
|
+
// CANCELLAZIONE (R3): cancel() ferma il timer E il polling in volo tramite
|
|
155
|
+
// isCancelled valutato dentro deliverBootstrapPrompt (ogni poll, pre-paste,
|
|
156
|
+
// pre-Enter); il chiamante fa cancel() + await settled prima dello spawn
|
|
157
|
+
// della generazione successiva: zero paste/Enter da un task della generazione
|
|
158
|
+
// precedente.
|
|
159
|
+
//
|
|
160
|
+
// REPORT: esito pubblicato con set-option -p @nc_delivery '<state>[:notReady]'
|
|
161
|
+
// (solo enum chiusi; l'opzione muore col pane, nessuno state file). Best
|
|
162
|
+
// effort: un set-option fallito lascia up() in report-timeout (onesto), la
|
|
163
|
+
// consegna resta fatta.
|
|
164
|
+
// CANCELLAZIONE (R3+R8): cancel() ferma il timer E il polling in volo tramite
|
|
165
|
+
// isCancelled valutato dentro deliverBootstrapPrompt (ogni poll, pre-paste,
|
|
166
|
+
// pre-Enter, post-Enter); il chiamante fa cancel() + await settled prima dello
|
|
167
|
+
// spawn della generazione successiva. cancel() con timer ANCORA PENDENTE
|
|
168
|
+
// (child uscito prima di readyMs, caso early-exit) risolve settled SUBITO:
|
|
169
|
+
// mai deadlock (R8). settled risolve con l'esito della delivery: null se
|
|
170
|
+
// nessun paste incerto, {state} se la generazione e' terminata con un
|
|
171
|
+
// post-paste incerto (delivery-unknown / staged-not-submitted) — il main loop
|
|
172
|
+
// NON auto-restarta in quel caso (R9: byte potenzialmente residui nel PTY del
|
|
173
|
+
// pane riusato; fermo bounded + restart operatore).
|
|
174
|
+
function startGenerationPrompt(config, generation, childState, seams = {}) {
|
|
175
|
+
if (!config) return null;
|
|
176
|
+
const classified = config.client === 'kimi' || config.client === 'claude';
|
|
177
|
+
if (!classified && generation === 0) return null; // legacy: gen0 resta al runtime
|
|
144
178
|
const setTimer = seams.setTimeout || setTimeout;
|
|
145
179
|
const clearTimer = seams.clearTimeout || clearTimeout;
|
|
180
|
+
const runTmux = seams.tmuxExec || ((bin, args, opts = {}) => new Promise((resolve) => {
|
|
181
|
+
require('node:child_process').execFile(bin, args, { env: opts.env, timeout: opts.timeoutMs || 10000 },
|
|
182
|
+
(err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code: err ? (typeof err.code === 'number' ? err.code : 1) : 0 }));
|
|
183
|
+
}));
|
|
184
|
+
const paneTarget = process.env.TMUX_PANE || `=${config.tmuxSession}`;
|
|
185
|
+
const markDelivery = async (value) => {
|
|
186
|
+
if (!classified) return;
|
|
187
|
+
try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_delivery', value], {}); }
|
|
188
|
+
catch (_) { /* best-effort: il report timeout di up() resta onesto */ }
|
|
189
|
+
};
|
|
190
|
+
let timer = null; let cancelled = false;
|
|
191
|
+
let settledDone = false; let settleResolve = null;
|
|
192
|
+
const settled = new Promise((resolve) => { settleResolve = resolve; });
|
|
193
|
+
const settle = (value) => {
|
|
194
|
+
if (settledDone) return;
|
|
195
|
+
settledDone = true;
|
|
196
|
+
settleResolve(value);
|
|
197
|
+
};
|
|
198
|
+
const isCancelled = () => cancelled || childState.exited === true;
|
|
146
199
|
timer = setTimer(async () => {
|
|
147
200
|
timer = null;
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
201
|
+
if (isCancelled()) { settle(null); return; }
|
|
202
|
+
const task = (async () => {
|
|
203
|
+
try {
|
|
204
|
+
if (classified) {
|
|
205
|
+
await markDelivery('');
|
|
206
|
+
const deliver = seams.deliverBootstrapPrompt
|
|
207
|
+
|| require('./prompt-delivery.js').deliverBootstrapPrompt;
|
|
208
|
+
const result = await deliver({
|
|
209
|
+
tmuxBin: config.tmuxBin,
|
|
210
|
+
session: config.tmuxSession,
|
|
211
|
+
prompt: config.prompt,
|
|
212
|
+
client: config.client,
|
|
213
|
+
paneTarget: process.env.TMUX_PANE || undefined,
|
|
214
|
+
readyWaitMs: config.readyWaitMs,
|
|
215
|
+
isCancelled,
|
|
216
|
+
});
|
|
217
|
+
const state = result && typeof result.state === 'string' ? result.state : '';
|
|
218
|
+
const uncertain = state === 'delivery-unknown' || state === 'staged-not-submitted';
|
|
219
|
+
if (isCancelled()) {
|
|
220
|
+
// R9: la generazione e' finita. Propago SOLO gli esiti post-paste
|
|
221
|
+
// incerti (residuo PTY possibile); cancelled pre-paste e' pulito.
|
|
222
|
+
return uncertain ? { state } : null;
|
|
223
|
+
}
|
|
224
|
+
if (state) {
|
|
225
|
+
const kind = state === 'skipped-not-ready' && result.notReady ? `:${result.notReady}` : '';
|
|
226
|
+
await markDelivery(`${state}${kind}`);
|
|
227
|
+
}
|
|
228
|
+
// R12: l'esito post-paste incerto va conservato ANCHE se il child
|
|
229
|
+
// era vivo al ritorno di deliver: i byte possono restare nel PTY del
|
|
230
|
+
// pane e sommarsi al bootstrap della generazione successiva. Al
|
|
231
|
+
// prossimo child exit il main ferma il supervisor (no auto-restart).
|
|
232
|
+
return uncertain ? { state } : null;
|
|
233
|
+
}
|
|
234
|
+
const inject = seams.injectPrompt || require('./launch.js').injectPrompt;
|
|
235
|
+
await inject(config.tmuxBin, config.tmuxSession, config.prompt, {
|
|
236
|
+
target: paneTarget,
|
|
237
|
+
readyMs: 0,
|
|
238
|
+
});
|
|
239
|
+
return null;
|
|
240
|
+
} catch (_) { return null; /* keepalive must not die because prompt reinjection failed */ }
|
|
241
|
+
})();
|
|
242
|
+
settle(await task);
|
|
156
243
|
}, config.readyMs ?? 400);
|
|
157
244
|
timer.unref?.();
|
|
158
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
settled,
|
|
247
|
+
// R8: timer ancora pendente -> settle IMMEDIATO (idempotente): il main
|
|
248
|
+
// loop non resta mai appeso su un child uscito prima di readyMs.
|
|
249
|
+
cancel() {
|
|
250
|
+
cancelled = true;
|
|
251
|
+
if (timer) { clearTimer(timer); timer = null; settle(null); }
|
|
252
|
+
},
|
|
253
|
+
};
|
|
159
254
|
}
|
|
160
255
|
|
|
161
256
|
async function main(argv = process.argv.slice(2), seams = {}) {
|
|
@@ -195,13 +290,26 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
195
290
|
for (;;) {
|
|
196
291
|
if (stopping) return 0;
|
|
197
292
|
const startedAt = now();
|
|
198
|
-
current = spawnImpl(payload.command, payload.args, { env: childEnv, stdio: 'inherit' });
|
|
199
293
|
const childState = { exited: false };
|
|
200
|
-
const
|
|
294
|
+
const promptCtl = startGenerationPrompt(payload.restartPrompt, generation, childState, seams);
|
|
295
|
+
current = spawnImpl(payload.command, payload.args, { env: childEnv, stdio: 'inherit' });
|
|
201
296
|
const result = await waitChild(current);
|
|
202
297
|
childState.exited = true;
|
|
203
|
-
prompt?.cancel();
|
|
204
298
|
current = null;
|
|
299
|
+
// R2/R3: la generazione e' finita. Cancella la delivery in volo e ATTESA
|
|
300
|
+
// del suo termine PRIMA di qualunque nuovo spawn. R9: se l'esito e' un
|
|
301
|
+
// post-paste incerto (delivery-unknown / staged-not-submitted) i byte del
|
|
302
|
+
// prompt possono essere residui nel PTY del pane riusato: NIENTE auto-
|
|
303
|
+
// restart — fermo bounded del supervisor, restart esplicito operatore.
|
|
304
|
+
if (promptCtl) {
|
|
305
|
+
promptCtl.cancel();
|
|
306
|
+
const promptOutcome = await promptCtl.settled;
|
|
307
|
+
if (promptOutcome && (promptOutcome.state === 'delivery-unknown'
|
|
308
|
+
|| promptOutcome.state === 'staged-not-submitted')) {
|
|
309
|
+
writeError('nexuscrew cell supervisor stopped: uncertain prompt delivery (operator restart required)\n');
|
|
310
|
+
return result.signal ? 128 : (result.code || 1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
205
313
|
if (result.error) {
|
|
206
314
|
writeError(`${sanitizeSpawnError(result.error, payload.command)}\n`);
|
|
207
315
|
return 1;
|
|
@@ -246,5 +354,5 @@ if (require.main === module) {
|
|
|
246
354
|
|
|
247
355
|
module.exports = {
|
|
248
356
|
DEFAULT_SUPERVISE, parseArgs, validSupervise, validRestartPrompt, validPayload,
|
|
249
|
-
receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild,
|
|
357
|
+
receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt, main,
|
|
250
358
|
};
|
package/lib/fleet/launch.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const os = require('node:os');
|
|
22
22
|
const path = require('node:path');
|
|
23
|
+
const crypto = require('node:crypto');
|
|
23
24
|
const { execFile } = require('node:child_process');
|
|
24
25
|
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
25
26
|
const { codeOf, phaseOf } = require('./causes.js');
|
|
@@ -355,34 +356,88 @@ async function waitStablePane(tmuxBin, target, { env, readyMs }) {
|
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
358
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
359
|
+
// Risolve il pane id (%N) esatto della sessione, o verifica un %N gia' noto.
|
|
360
|
+
// Contratto submitToSession (R5): output session_name + pane_dead + pane_id,
|
|
361
|
+
// parsing ESATTO per campi (mai regex %N libera), session verificata uguale
|
|
362
|
+
// a quella attesa prima di qualunque paste/Enter. Ritorna null se il pane non
|
|
363
|
+
// e' risolvibile, morto, di un'altra sessione o diverso dal %N atteso.
|
|
364
|
+
async function resolveSessionPane(tmuxBin, session, { env, exec, target } = {}) {
|
|
365
|
+
const run = exec || tmuxExec;
|
|
366
|
+
const to = target || `=${session}`;
|
|
367
|
+
const r = await run(tmuxBin,
|
|
368
|
+
['display-message', '-p', '-t', to, '#{session_name}\t#{pane_dead}\t#{pane_id}'],
|
|
369
|
+
{ env, timeoutMs: 2000 });
|
|
370
|
+
if (r.err) return null;
|
|
371
|
+
const fields = r.stdout.trim().split('\t');
|
|
372
|
+
if (fields.length !== 3) return null;
|
|
373
|
+
const [sess, dead, pane] = fields;
|
|
374
|
+
if (dead !== '0') return null; // morto o non bounded
|
|
375
|
+
if (!/^%[0-9]+$/.test(pane)) return null;
|
|
376
|
+
if (typeof session === 'string' && session && sess !== session) return null;
|
|
377
|
+
if (target && target.startsWith('%') && pane !== target) return null;
|
|
378
|
+
return pane;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Stadio sicuro del testo nel composer del TUI (0.8.47): temp file wx/0600 con
|
|
382
|
+
// nome random + buffer tmux random per invio + bracketed paste sul %N esatto
|
|
383
|
+
// risolto subito prima. NESSUN Enter: la sottomissione e' una decisione
|
|
384
|
+
// separata del chiamante (solo deliverBootstrapPrompt, per kimi.native e
|
|
385
|
+
// claude.kimi-code). Niente buffer condiviso 'ncsend': due celle concorrenti
|
|
386
|
+
// non possono piu' sovrascriversi il buffer a vicenda.
|
|
387
|
+
// Ritorna { ok, stage, paneId?, reason } con reason costante (mai stderr/pane).
|
|
388
|
+
async function securePaste(tmuxBin, session, text, { env, exec, tmpdir, target, fsImpl } = {}) {
|
|
389
|
+
const run = exec || tmuxExec;
|
|
390
|
+
const fsx = fsImpl || fs;
|
|
391
|
+
if (!promptCharsOk(text)) {
|
|
392
|
+
return { ok: false, stage: 'validate', reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
393
|
+
}
|
|
394
|
+
const paneId = await resolveSessionPane(tmuxBin, session, { env, exec: run, target });
|
|
395
|
+
if (!paneId) return { ok: false, stage: 'resolve', reason: 'pane non risolvibile' };
|
|
396
|
+
const nonce = crypto.randomBytes(8).toString('hex');
|
|
397
|
+
const buffer = `ncstage-${nonce}`;
|
|
398
|
+
const tmp = path.join(tmpdir || os.tmpdir(), `.ncstage-${process.pid}-${nonce}.txt`);
|
|
399
|
+
let loaded = false;
|
|
400
|
+
try {
|
|
401
|
+
fsx.writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
402
|
+
try { fsx.chmodSync(tmp, 0o600); } catch (_) { /* best-effort */ }
|
|
403
|
+
const load = await run(tmuxBin, ['load-buffer', '-b', buffer, tmp], { env });
|
|
404
|
+
if (load.err) return { ok: false, stage: 'load', reason: 'buffer non disponibile' };
|
|
405
|
+
loaded = true;
|
|
406
|
+
// R4: da qui in poi il paste e' stato TENTATO: qualunque throw/rejection
|
|
407
|
+
// dell'executor e' post-paste (stage 'paste'), mai classificato come
|
|
408
|
+
// pre-paste 'load' — il composer potrebbe contenere testo parziale o
|
|
409
|
+
// completo e un retry duplicherebbe il prompt (G1).
|
|
410
|
+
try {
|
|
411
|
+
const paste = await run(tmuxBin, ['paste-buffer', '-p', '-t', paneId, '-b', buffer], { env });
|
|
412
|
+
if (paste.err) return { ok: false, stage: 'paste', reason: 'paste-buffer failed' };
|
|
413
|
+
return { ok: true, stage: 'pasted', paneId };
|
|
414
|
+
} catch (_) {
|
|
415
|
+
return { ok: false, stage: 'paste', reason: 'paste-buffer failed' };
|
|
416
|
+
}
|
|
417
|
+
} catch (_) {
|
|
418
|
+
return { ok: false, stage: 'load', reason: 'buffer non disponibile' };
|
|
419
|
+
} finally {
|
|
420
|
+
try { fsx.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
421
|
+
if (loaded) { try { await run(tmuxBin, ['delete-buffer', '-b', buffer], { env }); } catch (_) { /* best-effort */ } }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Iniezione prompt send-keys via bracketed paste (contratto legacy per engine
|
|
426
|
+
// custom promptMode 'send-keys': paste SENZA Enter, invariato — 0.8.47 G4).
|
|
427
|
+
// Readiness best-effort: se la sessione non e' viva quando paste-iamo (command
|
|
428
|
+
// gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
|
|
429
|
+
// Trasporto backportato su securePaste: buffer random, temp wx/0600, pane %N.
|
|
362
430
|
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
363
431
|
if (!promptCharsOk(prompt)) {
|
|
364
432
|
return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
365
433
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const alive = await waitAlive(tmuxBin, session, { env, readyMs });
|
|
373
|
-
if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
|
|
374
|
-
|
|
375
|
-
// Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
|
|
376
|
-
// esatto, mai prefix-match) — audit impl #5.
|
|
377
|
-
const to = target || `=${session}`;
|
|
378
|
-
await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
|
|
379
|
-
const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
|
|
380
|
-
if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
|
|
381
|
-
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
382
|
-
} finally {
|
|
383
|
-
try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
384
|
-
try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
|
|
434
|
+
const alive = await waitAlive(tmuxBin, session, { env, readyMs });
|
|
435
|
+
if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
|
|
436
|
+
const stage = await securePaste(tmuxBin, session, prompt, { env, target });
|
|
437
|
+
if (!stage.ok) {
|
|
438
|
+
return { injected: false, reason: redactSecrets(stage.reason, engine, cell) };
|
|
385
439
|
}
|
|
440
|
+
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
386
441
|
}
|
|
387
442
|
|
|
388
443
|
module.exports = {
|
|
@@ -400,6 +455,8 @@ module.exports = {
|
|
|
400
455
|
waitAlive,
|
|
401
456
|
waitStablePane,
|
|
402
457
|
injectPrompt,
|
|
458
|
+
resolveSessionPane,
|
|
459
|
+
securePaste,
|
|
403
460
|
redactSecrets,
|
|
404
461
|
sanitizeEarlyDiagnostic,
|
|
405
462
|
};
|
package/lib/fleet/managed.js
CHANGED
|
@@ -921,7 +921,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
921
921
|
// TUI interattivo nella cwd della cella.
|
|
922
922
|
if (model) args.push('--model', model);
|
|
923
923
|
}
|
|
924
|
-
|
|
924
|
+
// Prompt su argv (0.8.47): SOLO i client che non hanno un percorso classified
|
|
925
|
+
// delivery. kimi.native e claude.kimi-code usano promptMode 'send-keys' con
|
|
926
|
+
// deliverBootstrapPrompt (readiness classificata + at-most-once): il prompt
|
|
927
|
+
// NON deve mai comparire nel loro argv (visibile in ps / perso dietro
|
|
928
|
+
// consenso/onboarding). Gli altri managed conservano il contratto argv
|
|
929
|
+
// (finding separato, fuori da questa patch).
|
|
930
|
+
const promptViaDelivery = spec.client === 'kimi'
|
|
931
|
+
|| (spec.client === 'claude' && spec.provider === 'kimi-code');
|
|
932
|
+
if (spec.client !== 'shell' && !promptViaDelivery && cell?.prompt) args.push(cell.prompt);
|
|
925
933
|
// nexuscrew-store source: neutralize the profile's env set in the composed
|
|
926
934
|
// child env (unset, never empty), so the runtime cannot leak credentials that
|
|
927
935
|
// the local store is meant to own.
|
|
@@ -933,7 +941,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
933
941
|
}
|
|
934
942
|
return { ok: true, info, engine: {
|
|
935
943
|
...engine, command, args, env,
|
|
936
|
-
promptMode:
|
|
944
|
+
promptMode: promptViaDelivery ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
|
|
937
945
|
...(spec.client === 'shell' ? { shellOneShot } : {}),
|
|
938
946
|
} };
|
|
939
947
|
}
|
|
@@ -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
|
+
};
|