@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.
@@ -11,8 +11,8 @@
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-DMG-rioF.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BAq6N1Md.css">
14
+ <script type="module" crossorigin src="/assets/index-C_SyIZ78.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-43DFO1EH.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.46"}
1
+ {"version":"0.8.48"}
@@ -5,6 +5,19 @@ const { isValidSession } = require('../files/store.js');
5
5
  const { submitTextOk } = require('../tmux/actions.js');
6
6
 
7
7
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
8
+ const CELL_LABEL_MAX = 64;
9
+
10
+ // Una label che esce da questo nodo, o che arriva da un altro, e' testo
11
+ // AUTO-DICHIARATO: la definizione locale e' gia' validata dal parser, ma il
12
+ // payload che si espone e quello che si riceve vanno delimitati comunque.
13
+ // Senza questo un peer puo' far attraversare la directory a una stringa lunga
14
+ // e con a capo, che ogni consumatore poi renderizza.
15
+ function safeCellLabel(value) {
16
+ if (typeof value !== 'string') return '';
17
+ const trimmed = value.trim();
18
+ if (!trimmed || trimmed.length > CELL_LABEL_MAX) return '';
19
+ return /[\x00-\x1f\x7f]/.test(trimmed) ? '' : trimmed;
20
+ }
8
21
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
9
22
  const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
10
23
 
@@ -24,6 +37,10 @@ function publicCells(status, instanceId, now = Date.now()) {
24
37
  id: `${instanceId}:${raw.cell}`,
25
38
  instanceId,
26
39
  cell: raw.cell,
40
+ // Il nome leggibile viaggia accanto all'id, mai al suo posto: chi riceve
41
+ // questa voce deve poter capire che ruolo occupa la cella senza dover
42
+ // interpretare un identificatore scelto da un altro nodo.
43
+ label: safeCellLabel(raw.label),
27
44
  tmuxSession: raw.tmuxSession,
28
45
  engine: typeof raw.engine === 'string' ? raw.engine : '',
29
46
  model: typeof raw.model === 'string' ? raw.model : '',
@@ -135,4 +152,4 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
135
152
  return r;
136
153
  }
137
154
 
138
- module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
155
+ module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity, safeCellLabel };
@@ -507,7 +507,10 @@ async function createBuiltinFleet(cfg = {}) {
507
507
  if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
508
508
  throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
509
509
  }
510
- const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt']);
510
+ // `label` e' parte della definizione quanto `prompt`: senza di essa qui, un
511
+ // backup che la contiene verrebbe rifiutato in restore e il round-trip si
512
+ // spezzerebbe proprio sulle celle a cui e' stato dato un nome.
513
+ const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt', 'label']);
511
514
  const seen = new Set();
512
515
  for (const cell of cells) {
513
516
  if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
@@ -864,6 +867,9 @@ async function createBuiltinFleet(cfg = {}) {
864
867
  permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
865
868
  commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
866
869
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
870
+ // Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
871
+ // indirizzamento e l'unica origine della sessione tmux.
872
+ label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
867
873
  },
868
874
  };
869
875
  }
@@ -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
- function scheduleRestartPrompt(config, childState, seams = {}) {
142
- if (!config) return { cancel() {} };
143
- let timer = null; let cancelled = false;
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 (cancelled || childState.exited) return;
149
- try {
150
- const inject = seams.injectPrompt || require('./launch.js').injectPrompt;
151
- await inject(config.tmuxBin, config.tmuxSession, config.prompt, {
152
- target: process.env.TMUX_PANE || `=${config.tmuxSession}`,
153
- readyMs: 0,
154
- });
155
- } catch (_) { /* keepalive must not die because prompt reinjection failed */ }
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 { cancel() { cancelled = true; if (timer) clearTimer(timer); timer = null; } };
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 prompt = generation > 0 ? scheduleRestartPrompt(payload.restartPrompt, childState, seams) : null;
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, scheduleRestartPrompt, main,
357
+ receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt, main,
250
358
  };
@@ -378,6 +378,21 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
378
378
  prompt = c.prompt;
379
379
  }
380
380
 
381
+ // label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
382
+ // L'id resta la chiave stabile con cui si indirizza una cella e con cui si
383
+ // deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
384
+ // questa distinzione l'id fa anche da nome, e un nodo che battezza la propria
385
+ // cella come il motore la espone cosi' a tutta la rete: chi la riceve non ha
386
+ // modo di sapere che ruolo occupa. Stessa regola gia' usata per la label
387
+ // degli engine e per quella dei nodi: stampabile, non vuota, max 64.
388
+ let label;
389
+ if (c.label !== undefined) {
390
+ if (typeof c.label !== 'string') return null;
391
+ const trimmed = c.label.trim();
392
+ if (!trimmed || trimmed.length > MAX_LABEL_LEN || !isPrintable(trimmed)) return null;
393
+ label = trimmed;
394
+ }
395
+
381
396
  // tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
382
397
  // Il nome CANONICO e' tmux-safe (v2 per id puntati): tmux normalizza '.' in
383
398
  // '_' nei nomi sessione, per cui `cloud-agy.native` diverrebbe `cloud-agy_native`
@@ -422,6 +437,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
422
437
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
423
438
  if (commands && Object.keys(commands).length) out.commands = commands;
424
439
  if (prompt !== undefined) out.prompt = prompt;
440
+ if (label !== undefined) out.label = label;
425
441
  if (legacyTmuxSession) {
426
442
  Object.defineProperty(out, 'legacyTmuxSession', {
427
443
  value: legacyTmuxSession, enumerable: false, configurable: false,
@@ -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
- // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
359
- // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
360
- // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
361
- // (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
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
- let tmp = null;
367
- try {
368
- tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
369
- fs.writeFileSync(tmp, prompt, { mode: 0o600 });
370
- fs.chmodSync(tmp, 0o600);
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
  };
@@ -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
- if (spec.client !== 'shell' && spec.client !== 'kimi' && cell?.prompt) args.push(cell.prompt);
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: spec.client === 'kimi' ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
944
+ promptMode: promptViaDelivery ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
937
945
  ...(spec.client === 'shell' ? { shellOneShot } : {}),
938
946
  } };
939
947
  }