@mmmbuto/nexuscrew 0.8.39 → 0.8.41
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 +56 -1
- package/README.md +2 -1
- package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/cli/doctor.js +37 -0
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +2 -1
- package/lib/fleet/launch.js +16 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/fleet/runtime.js +14 -1
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +97 -3
- package/lib/settings/routes.js +150 -4
- package/lib/tmux/lifecycle.js +24 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +30 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/routes.js — route audio locali: /speak, /stop, /capability, /status.
|
|
3
|
+
//
|
|
4
|
+
// Due invarianti reggono tutto il resto.
|
|
5
|
+
//
|
|
6
|
+
// 1) L'origine e' verificata, non dichiarata. `originResolver` e' obbligatorio e
|
|
7
|
+
// autoritativo: nessun header, campo del body o parametro di query concorre a
|
|
8
|
+
// stabilire chi sta parlando. Senza un'origine verificabile la richiesta
|
|
9
|
+
// muore con 401, non con un default permissivo.
|
|
10
|
+
//
|
|
11
|
+
// 2) Ogni decisione di suonare la prende il nodo che possiede l'altoparlante. Se
|
|
12
|
+
// il target e' remoto questo server non decide per lui: instrada e riporta
|
|
13
|
+
// l'esito per endpoint cosi' com'e', incluso `unknown`.
|
|
14
|
+
//
|
|
15
|
+
// Il corpo grezzo viene conservato (`req.rawBody`) perche' la firma del bridge
|
|
16
|
+
// copre i BYTE trasmessi: ri-serializzare il JSON per verificarlo produrrebbe
|
|
17
|
+
// una stringa diversa a parita' di significato.
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const { handleSpeak, handleStop, isValidInstance } = require('./speak.js');
|
|
20
|
+
const { describeCapability } = require('./capability.js');
|
|
21
|
+
const { createReceiptStore } = require('./receipt.js');
|
|
22
|
+
const { createSpeakRateLimiter } = require('./rate-limit.js');
|
|
23
|
+
const { createGroupReceiptStore } = require('./group-receipt.js');
|
|
24
|
+
const { createGroupSpeaker } = require('./group-speak.js');
|
|
25
|
+
|
|
26
|
+
const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
27
|
+
const LANG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,34})$/;
|
|
28
|
+
const VOICE_RE = /^[^\u0000-\u001f\u007f]{1,64}$/;
|
|
29
|
+
const URGENCIES = new Set(['normal', 'high']);
|
|
30
|
+
|
|
31
|
+
function plainObject(value) {
|
|
32
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function onlyKeys(value, allowed) {
|
|
36
|
+
return plainObject(value) && Object.keys(value).every((key) => allowed.has(key));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function optionalBounded(value, re) {
|
|
40
|
+
return value === undefined || (typeof value === 'string' && re.test(value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Il server non deve diventare tollerante verso campi futuri/non documentati:
|
|
44
|
+
// testo, route o identita' inattesi non vengono mai ignorati in silenzio.
|
|
45
|
+
// `originCell`/`originNode` sono gli unici campi aggiunti dal dispatcher sul
|
|
46
|
+
// percorso federato; il target li ricontrolla nel resolver, non li usa mai
|
|
47
|
+
// come fonte primaria dell'identita'.
|
|
48
|
+
function parseSpeakBody(body, { federated = false } = {}) {
|
|
49
|
+
const allowed = new Set(['target', 'text', 'lang', 'voice', 'urgency', 'utteranceId']);
|
|
50
|
+
if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
|
|
51
|
+
if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
|
|
52
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
53
|
+
if (!isValidInstance(target)) return { error: 'invalid-target' };
|
|
54
|
+
if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
|
|
55
|
+
if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
|
|
56
|
+
if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
|
|
57
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
58
|
+
return { error: 'invalid-utterance' };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
target, text: body.text,
|
|
62
|
+
...(body.lang !== undefined ? { lang: body.lang } : {}),
|
|
63
|
+
...(body.voice !== undefined ? { voice: body.voice } : {}),
|
|
64
|
+
urgency: body.urgency || 'normal',
|
|
65
|
+
...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseStopBody(body, { federated = false } = {}) {
|
|
70
|
+
const allowed = new Set(['target', 'utteranceId']);
|
|
71
|
+
if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
|
|
72
|
+
if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
|
|
73
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
74
|
+
if (!isValidInstance(target)) return { error: 'invalid-target' };
|
|
75
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
76
|
+
return { error: 'invalid-utterance' };
|
|
77
|
+
}
|
|
78
|
+
return { target, ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseRemoteStatusBody(body) {
|
|
82
|
+
if (!onlyKeys(body, new Set(['target', 'utteranceId', 'originCell', 'originNode']))) return { error: 'invalid-request' };
|
|
83
|
+
if (!isValidInstance(body.target)) return { error: 'invalid-target' };
|
|
84
|
+
if (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId)) return { error: 'invalid-utterance' };
|
|
85
|
+
return { target: body.target, utteranceId: body.utteranceId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseGroupSpeakBody(body) {
|
|
89
|
+
if (!onlyKeys(body, new Set(['group', 'text', 'lang', 'voice', 'urgency', 'utteranceId']))) return { error: 'invalid-request' };
|
|
90
|
+
if (typeof body.group !== 'string') return { error: 'invalid-group' };
|
|
91
|
+
if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
|
|
92
|
+
if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
|
|
93
|
+
if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
|
|
94
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
95
|
+
return { error: 'invalid-utterance' };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
group: body.group, text: body.text, urgency: body.urgency || 'normal',
|
|
99
|
+
...(body.lang !== undefined ? { lang: body.lang } : {}),
|
|
100
|
+
...(body.voice !== undefined ? { voice: body.voice } : {}),
|
|
101
|
+
...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function audioRoutes(deps = {}) {
|
|
106
|
+
if (!deps.originResolver || typeof deps.originResolver.resolve !== 'function') {
|
|
107
|
+
throw new Error('audioRoutes: originResolver e obbligatorio (origine verificata)');
|
|
108
|
+
}
|
|
109
|
+
const r = express.Router();
|
|
110
|
+
r.use(express.json({
|
|
111
|
+
limit: '16kb',
|
|
112
|
+
verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf); },
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const originResolver = deps.originResolver;
|
|
116
|
+
const receiptStore = deps.receiptStore || createReceiptStore();
|
|
117
|
+
const rateLimiter = deps.rateLimiter || createSpeakRateLimiter();
|
|
118
|
+
const readonly = deps.readonly || (() => false);
|
|
119
|
+
const localNodeId = deps.localNodeId || (() => null);
|
|
120
|
+
const consent = deps.consent || (() => false);
|
|
121
|
+
const adapter = deps.adapter || null;
|
|
122
|
+
const queue = deps.queue || null;
|
|
123
|
+
const acl = deps.acl || null;
|
|
124
|
+
const dispatcher = deps.dispatcher || null;
|
|
125
|
+
const getGroup = deps.getGroup || (() => null);
|
|
126
|
+
const groupReceipts = deps.groupReceipts || createGroupReceiptStore();
|
|
127
|
+
const now = deps.now || Date.now;
|
|
128
|
+
|
|
129
|
+
const send = (res, code, body) => res.status(code).json(body);
|
|
130
|
+
|
|
131
|
+
// Un solo messaggio per ogni fallimento di origine. Distinguere "firma
|
|
132
|
+
// scaduta" da "nonce riusato" da "cella non attiva" darebbe a chi prova a
|
|
133
|
+
// forgiare un oracolo gratuito su cosa manca per riuscire.
|
|
134
|
+
async function originOr401(req, res, opts = {}) {
|
|
135
|
+
const resolved = await originResolver.resolve(req, opts);
|
|
136
|
+
if (!resolved || resolved.ok !== true) {
|
|
137
|
+
send(res, 401, { error: 'origine non verificabile' });
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return resolved;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gateDeps(origin, attested) {
|
|
144
|
+
return {
|
|
145
|
+
readonly, rateLimiter, queue, localNodeId, consent, now,
|
|
146
|
+
acl: acl ? acl.allows : null,
|
|
147
|
+
receipt: (status, o) => receiptStore.record({
|
|
148
|
+
origin: o.origin, target: o.target, status, reason: o.reason,
|
|
149
|
+
utteranceId: o.utteranceId, attested,
|
|
150
|
+
}),
|
|
151
|
+
updateReceipt: (id, status, reason) => receiptStore.update(id, status, reason),
|
|
152
|
+
readReceipt: (id, o) => receiptStore.get(o, id),
|
|
153
|
+
findReceipt: (id, o, target) => receiptStore.find(id, o, target),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function localCapability(target) {
|
|
158
|
+
const self = localNodeId();
|
|
159
|
+
if (!self || target !== self) return { status: 'refused', reason: 'invalid-target' };
|
|
160
|
+
const described = describeCapability({ adapter, consent: consent() === true, nodeId: self });
|
|
161
|
+
if (described.consent !== true) return { status: 'refused', reason: 'consent' };
|
|
162
|
+
if (described.installed !== true) return { status: 'refused', reason: 'no-adapter' };
|
|
163
|
+
if (described.liveness !== 'ready') return { status: 'unknown', reason: 'liveness-unknown' };
|
|
164
|
+
return { status: 'ready', capability: described };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function capabilityForGroup({ target }) {
|
|
168
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
169
|
+
if (target === localNodeId()) return localCapability(target);
|
|
170
|
+
if (!dispatcher || typeof dispatcher.probeCapability !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
|
|
171
|
+
return dispatcher.probeCapability(target);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function speakForGroup({ target, origin, utteranceId, text, lang, voice, urgency }) {
|
|
175
|
+
if (target === localNodeId()) {
|
|
176
|
+
return handleSpeak({
|
|
177
|
+
target, text, lang, voice, urgency, utteranceId, origin, trust: 'local-bridge', visited: [],
|
|
178
|
+
}, gateDeps(origin, false));
|
|
179
|
+
}
|
|
180
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
|
|
181
|
+
// Un gruppo non e' una scorciatoia attorno al budget `nc_speak`: prima del
|
|
182
|
+
// dispatch remoto consuma gli stessi tre bucket sul nodo origine. Il target
|
|
183
|
+
// li applichera' di nuovo in modo indipendente, perche' resta l'autorita'
|
|
184
|
+
// finale sul proprio altoparlante.
|
|
185
|
+
const rl = rateLimiter.check({ origin, target, urgency });
|
|
186
|
+
if (!rl.allowed) return { status: 'refused', reason: 'rate-limit' };
|
|
187
|
+
return dispatcher.dispatch({
|
|
188
|
+
resource: '/audio/speak', target, origin,
|
|
189
|
+
payload: { text, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), urgency, utteranceId },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function statusForGroup({ target, origin, utteranceId }) {
|
|
194
|
+
if (target === localNodeId()) return receiptStore.get(origin, utteranceId) || { status: 'unknown', reason: 'not-found' };
|
|
195
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
|
|
196
|
+
return dispatcher.dispatch({ resource: '/audio/speak/status', target, origin, payload: { utteranceId } });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function stopForGroup({ target, origin, utteranceId }) {
|
|
200
|
+
if (target === localNodeId()) return handleStop({ target, utteranceId, origin }, gateDeps(origin, false));
|
|
201
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
|
|
202
|
+
return dispatcher.dispatch({ resource: '/audio/stop', target, origin, payload: { utteranceId } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const groupSpeaker = deps.groupSpeaker || createGroupSpeaker({
|
|
206
|
+
getGroup, receipts: groupReceipts,
|
|
207
|
+
capability: capabilityForGroup, speak: speakForGroup,
|
|
208
|
+
status: statusForGroup, stop: stopForGroup,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
r.post('/speak', async (req, res) => {
|
|
212
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
213
|
+
const { origin, trust, visited } = resolved;
|
|
214
|
+
const parsed = parseSpeakBody(req.body, { federated: trust === 'federated' });
|
|
215
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
216
|
+
const { target, utteranceId } = parsed;
|
|
217
|
+
// Gruppo e speak singolo condividono il namespace dell'utteranceId per
|
|
218
|
+
// origine. Senza questo gate un id riusato potrebbe far sembrare `spoken`
|
|
219
|
+
// un endpoint che aveva pronunciato una richiesta precedente, diversa.
|
|
220
|
+
if (utteranceId && groupReceipts.get(origin, utteranceId)) {
|
|
221
|
+
return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
|
|
222
|
+
}
|
|
223
|
+
// Target remoto: questo nodo non decide e non parla. Instrada sulla
|
|
224
|
+
// topologia autorizzata e restituisce l'esito del target senza addolcirlo.
|
|
225
|
+
if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
|
|
226
|
+
if (readonly()) return send(res, 403, { status: 'refused', reason: 'readonly' });
|
|
227
|
+
const rl = rateLimiter.check({ origin, target, urgency: parsed.urgency });
|
|
228
|
+
if (!rl.allowed) return send(res, 429, { status: 'refused', reason: 'rate-limit' });
|
|
229
|
+
const local = receiptStore.record({
|
|
230
|
+
origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
|
|
231
|
+
});
|
|
232
|
+
const out = await dispatcher.dispatch({
|
|
233
|
+
resource: '/audio/speak', target, origin,
|
|
234
|
+
payload: {
|
|
235
|
+
text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
|
|
236
|
+
utteranceId: local.utteranceId,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const merged = receiptStore.update(local.utteranceId, out.status, out.reason) || {
|
|
240
|
+
...local, status: out.status, ...(out.reason ? { reason: out.reason } : {}),
|
|
241
|
+
};
|
|
242
|
+
const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
|
|
243
|
+
: out.status === 'unreachable' ? 503 : 422;
|
|
244
|
+
return send(res, code, merged);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const out = handleSpeak({
|
|
248
|
+
target, text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
|
|
249
|
+
utteranceId, origin, trust, visited,
|
|
250
|
+
}, gateDeps(origin, trust === 'federated'));
|
|
251
|
+
const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
|
|
252
|
+
: (out.reason === 'invalid-text' || out.reason === 'invalid-target' || out.reason === 'not-local-target') ? 400
|
|
253
|
+
: out.status === 'unreachable' ? 503 : 422;
|
|
254
|
+
return send(res, code, out);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
r.post('/stop', async (req, res) => {
|
|
258
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
259
|
+
const { origin } = resolved;
|
|
260
|
+
const parsed = parseStopBody(req.body, { federated: resolved.trust === 'federated' });
|
|
261
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
262
|
+
const { target } = parsed;
|
|
263
|
+
if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
|
|
264
|
+
const out = await dispatcher.dispatch({
|
|
265
|
+
resource: '/audio/stop', target, origin,
|
|
266
|
+
payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
|
|
267
|
+
});
|
|
268
|
+
return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
|
|
269
|
+
}
|
|
270
|
+
const out = handleStop({ target, utteranceId: parsed.utteranceId, origin }, gateDeps(origin, false));
|
|
271
|
+
return send(res, out.status === 'accepted' || out.status === 'unknown' ? 200 : 422, out);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Capability del SOLO nodo locale. Non e' una directory: un chiamante non puo'
|
|
275
|
+
// usare questo endpoint per sondare la capability di un terzo nodo, che va
|
|
276
|
+
// interrogato sulla propria route federata con la credenziale giusta.
|
|
277
|
+
r.get('/capability', async (req, res) => {
|
|
278
|
+
const resolved = await originOr401(req, res, { requireCell: false }); if (!resolved) return undefined;
|
|
279
|
+
if (resolved.trust === 'federated' && acl && typeof acl.allows === 'function') {
|
|
280
|
+
const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
|
|
281
|
+
if (!verdict || verdict.allowed !== true) return send(res, 403, { error: 'capability non autorizzata' });
|
|
282
|
+
}
|
|
283
|
+
if (Object.keys(req.query || {}).some((key) => key !== 'target') || Array.isArray(req.query && req.query.target)) {
|
|
284
|
+
return send(res, 400, { error: 'query non valida' });
|
|
285
|
+
}
|
|
286
|
+
const self = localNodeId();
|
|
287
|
+
const target = String(req.query.target || self || '');
|
|
288
|
+
if (!isValidInstance(target)) return send(res, 400, { error: 'target non valido (instanceId 32 hex)' });
|
|
289
|
+
if (!self || target !== self) return send(res, 403, { error: 'capability descrive solo il nodo locale' });
|
|
290
|
+
return send(res, 200, describeCapability({ adapter, consent: consent() === true, nodeId: self }));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Status per utteranceId, leggibile SOLO dalla stessa origine (nodo+cella).
|
|
294
|
+
// Per chiunque altro l'enunciato non esiste: distinguere 404 da 403
|
|
295
|
+
// permetterebbe di enumerare gli id altrui.
|
|
296
|
+
r.get('/speak/status/:utteranceId', async (req, res) => {
|
|
297
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
298
|
+
const rc = receiptStore.get(resolved.origin, String(req.params.utteranceId || ''));
|
|
299
|
+
if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
300
|
+
return send(res, 200, rc);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Status federato: POST e non GET perche' la cella attestata del chiamante
|
|
304
|
+
// viaggia nel body costruito dal dispatcher. Non e' una API per browser: la
|
|
305
|
+
// whitelist Hydra e la prova hop rendono il percorso raggiungibile solo da un
|
|
306
|
+
// origin node gia' autenticato. Il target riapplica l'ACL prima di rivelare
|
|
307
|
+
// anche il receipt redatto.
|
|
308
|
+
r.post('/speak/status', async (req, res) => {
|
|
309
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
310
|
+
if (resolved.trust !== 'federated') return send(res, 403, { status: 'refused', reason: 'federated-only' });
|
|
311
|
+
const parsed = parseRemoteStatusBody(req.body);
|
|
312
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
313
|
+
if (parsed.target !== localNodeId()) return send(res, 403, { status: 'refused', reason: 'not-local-target' });
|
|
314
|
+
if (acl && typeof acl.allows === 'function') {
|
|
315
|
+
const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
|
|
316
|
+
if (!verdict || verdict.allowed !== true) return send(res, 422, { status: 'refused', reason: 'acl' });
|
|
317
|
+
}
|
|
318
|
+
const rc = receiptStore.get(resolved.origin, parsed.utteranceId);
|
|
319
|
+
if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
320
|
+
return send(res, 200, rc);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// I gruppi sono locali all'origine e non fanno parte della whitelist
|
|
324
|
+
// federata: la loro espansione avviene qui, quindi ogni endpoint riceve un
|
|
325
|
+
// comando /audio/speak esatto e applica autonomamente consenso/ACL/READONLY.
|
|
326
|
+
r.post('/groups/speak', async (req, res) => {
|
|
327
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
328
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
329
|
+
const parsed = parseGroupSpeakBody(req.body);
|
|
330
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
331
|
+
if (readonly()) return send(res, 422, { status: 'refused', reason: 'readonly' });
|
|
332
|
+
if (parsed.utteranceId && receiptStore.get(resolved.origin, parsed.utteranceId)) {
|
|
333
|
+
return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
|
|
334
|
+
}
|
|
335
|
+
const out = await groupSpeaker.speakGroup({ origin: resolved.origin, ...parsed });
|
|
336
|
+
const code = out.status === 'refused' ? 422 : 200;
|
|
337
|
+
return send(res, code, out);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
r.get('/groups/status/:utteranceId', async (req, res) => {
|
|
341
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
342
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
343
|
+
const utteranceId = String(req.params.utteranceId || '');
|
|
344
|
+
if (!UTTERANCE_ID_RE.test(utteranceId)) return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
|
|
345
|
+
const receipt = groupSpeaker.getStatus({ origin: resolved.origin, utteranceId });
|
|
346
|
+
if (!receipt) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
347
|
+
return send(res, 200, receipt);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
r.post('/groups/stop', async (req, res) => {
|
|
351
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
352
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
353
|
+
if (!onlyKeys(req.body, new Set(['utteranceId'])) || typeof req.body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(req.body.utteranceId)) {
|
|
354
|
+
return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
|
|
355
|
+
}
|
|
356
|
+
const out = await groupSpeaker.stopGroup({ origin: resolved.origin, utteranceId: req.body.utteranceId });
|
|
357
|
+
return send(res, out.status === 'refused' ? 422 : 200, out);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
r.use((err, _req, res, _next) => {
|
|
361
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
362
|
+
return res.status(413).json({ error: 'body troppo grande' });
|
|
363
|
+
}
|
|
364
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
|
|
365
|
+
return res.status(err.status || 400).json({ error: 'richiesta audio non valida' });
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return r;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
audioRoutes, UTTERANCE_ID_RE, LANG_RE, VOICE_RE,
|
|
373
|
+
parseSpeakBody, parseStopBody, parseGroupSpeakBody,
|
|
374
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/speak.js — catena di gate LATO TARGET: la decisione di emettere
|
|
3
|
+
// suono la prende il nodo che possiede l'altoparlante, sempre, anche quando la
|
|
4
|
+
// richiesta e' gia' passata da una route federata autorizzata.
|
|
5
|
+
//
|
|
6
|
+
// Ordine dei gate, deliberato:
|
|
7
|
+
// 0. testo valido (1..320) — nessun effetto collaterale
|
|
8
|
+
// 1. target esatto e uguale a QUESTO nodo — un nodo parla solo per se'
|
|
9
|
+
// 2. READONLY — `speak` e' una mutazione: ha
|
|
10
|
+
// un effetto fisico osservabile, quindi in sola lettura si rifiuta
|
|
11
|
+
// 3. ACL sull'origine
|
|
12
|
+
// 4. consenso audio locale (default OFF)
|
|
13
|
+
// 5. idempotenza per utteranceId — PRIMA del rate limit, cosi' un
|
|
14
|
+
// retry identico non consuma budget e non produce una seconda voce
|
|
15
|
+
// 6. rate limit dedicato
|
|
16
|
+
// 7. adapter/coda presenti — senza adapter si dice no, non
|
|
17
|
+
// si finge un `accepted`
|
|
18
|
+
// 8. accodamento
|
|
19
|
+
//
|
|
20
|
+
// I gate 3 e 4 stanno prima dell'idempotenza di proposito: un rifiuto per ACL o
|
|
21
|
+
// consenso deve restare un rifiuto anche al secondo tentativo, non trasformarsi
|
|
22
|
+
// in un receipt riusato.
|
|
23
|
+
const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
|
|
24
|
+
const TEXT_MAX = 320;
|
|
25
|
+
|
|
26
|
+
function isValidInstance(v) {
|
|
27
|
+
return typeof v === 'string' && INSTANCE_ID_RE.test(v);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// handleSpeak(): ritorna SEMPRE uno stato per endpoint, mai un booleano.
|
|
31
|
+
// `deps.receipt(status, opts)` registra e ritorna il receipt redatto.
|
|
32
|
+
function handleSpeak(input, deps) {
|
|
33
|
+
const opts = input || {};
|
|
34
|
+
const d = deps || {};
|
|
35
|
+
const { target, origin } = opts;
|
|
36
|
+
|
|
37
|
+
if (typeof opts.text !== 'string' || opts.text.length < 1 || opts.text.length > TEXT_MAX) {
|
|
38
|
+
return { status: 'refused', reason: 'invalid-text' };
|
|
39
|
+
}
|
|
40
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
41
|
+
// Un target diverso da questo nodo non e' affar suo: il nodo non ritrasmette
|
|
42
|
+
// e non fa da relay applicativo per la voce di un altro.
|
|
43
|
+
if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
|
|
44
|
+
return { status: 'refused', reason: 'not-local-target' };
|
|
45
|
+
}
|
|
46
|
+
if (typeof d.readonly === 'function' && d.readonly()) {
|
|
47
|
+
return d.receipt('refused', { reason: 'readonly', target, origin, utteranceId: opts.utteranceId });
|
|
48
|
+
}
|
|
49
|
+
if (typeof d.acl === 'function') {
|
|
50
|
+
const verdict = d.acl({ trust: opts.trust, origin, visited: opts.visited });
|
|
51
|
+
if (!verdict || verdict.allowed !== true) {
|
|
52
|
+
// La reason resta generica verso l'esterno: la ragione precisa del rifiuto
|
|
53
|
+
// descriverebbe la topologia del nodo a chi non e' autorizzato a vederla.
|
|
54
|
+
return d.receipt('refused', { reason: 'acl', target, origin, utteranceId: opts.utteranceId });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (typeof d.consent === 'function' && d.consent() !== true) {
|
|
58
|
+
return d.receipt('refused', { reason: 'consent', target, origin, utteranceId: opts.utteranceId });
|
|
59
|
+
}
|
|
60
|
+
if (opts.utteranceId && typeof d.findReceipt === 'function') {
|
|
61
|
+
const found = d.findReceipt(opts.utteranceId, origin, target);
|
|
62
|
+
if (found === 'collision') {
|
|
63
|
+
const ts = typeof d.now === 'function' ? d.now() : Date.now();
|
|
64
|
+
return {
|
|
65
|
+
status: 'refused', reason: 'utterance-collision', utteranceId: opts.utteranceId,
|
|
66
|
+
origin: { node: origin && origin.node, cell: origin && origin.cell }, target, timestamp: ts,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (found) return found;
|
|
70
|
+
}
|
|
71
|
+
if (d.rateLimiter) {
|
|
72
|
+
const rl = d.rateLimiter.check({ origin, target, urgency: opts.urgency });
|
|
73
|
+
if (!rl.allowed) {
|
|
74
|
+
return d.receipt('refused', { reason: 'rate-limit', target, origin, utteranceId: opts.utteranceId });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!d.queue || typeof d.queue.enqueue !== 'function') {
|
|
78
|
+
return d.receipt('refused', { reason: 'no-adapter', target, origin, utteranceId: opts.utteranceId });
|
|
79
|
+
}
|
|
80
|
+
// Il receipt nasce PRIMA dell'accodamento: la coda aggiornera' questo stesso
|
|
81
|
+
// utteranceId quando l'adapter conferma l'avvio, fallisce o va in timeout.
|
|
82
|
+
const receipt = d.receipt('accepted', { target, origin, utteranceId: opts.utteranceId });
|
|
83
|
+
const admitted = d.queue.enqueue({
|
|
84
|
+
utteranceId: receipt.utteranceId,
|
|
85
|
+
text: opts.text, lang: opts.lang, voice: opts.voice, urgency: opts.urgency,
|
|
86
|
+
});
|
|
87
|
+
if (admitted.status !== 'accepted') {
|
|
88
|
+
return (d.updateReceipt && d.updateReceipt(receipt.utteranceId, admitted.status, admitted.reason))
|
|
89
|
+
|| { ...receipt, status: admitted.status, ...(admitted.reason ? { reason: admitted.reason } : {}) };
|
|
90
|
+
}
|
|
91
|
+
// La coda puo' aver gia' concluso in modo sincrono (adapter fake nei test,
|
|
92
|
+
// fallimento immediato di spawn): si rilegge il receipt invece di riportare
|
|
93
|
+
// uno stato ormai superato.
|
|
94
|
+
return (d.readReceipt && d.readReceipt(receipt.utteranceId, origin)) || receipt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// handleStop(): comando di controllo, non sintesi. Lo stop LOCALE e' sovrano e
|
|
98
|
+
// non dipende dalla rete: un endpoint deve poter tacere anche offline. Uno stop
|
|
99
|
+
// remoto per utteranceId vale solo per gli enunciati del chiamante.
|
|
100
|
+
function handleStop(input, deps) {
|
|
101
|
+
const opts = input || {};
|
|
102
|
+
const d = deps || {};
|
|
103
|
+
const { target, origin } = opts;
|
|
104
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
105
|
+
if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
|
|
106
|
+
return { status: 'refused', reason: 'not-local-target' };
|
|
107
|
+
}
|
|
108
|
+
// READONLY non blocca lo stop: fermare una voce e' sempre permesso. Impedirlo
|
|
109
|
+
// significherebbe che un nodo in sola lettura non puo' zittirsi.
|
|
110
|
+
if (opts.utteranceId) {
|
|
111
|
+
if (typeof d.findReceipt === 'function') {
|
|
112
|
+
const found = d.findReceipt(opts.utteranceId, origin, target);
|
|
113
|
+
if (!found || found === 'collision') {
|
|
114
|
+
return { status: 'unknown', reason: 'utterance-not-found', utteranceId: opts.utteranceId };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const acted = d.queue && typeof d.queue.stop === 'function' ? d.queue.stop(opts.utteranceId) : false;
|
|
118
|
+
if (d.updateReceipt && acted) d.updateReceipt(opts.utteranceId, 'refused', 'stopped');
|
|
119
|
+
return { status: 'accepted', utteranceId: opts.utteranceId, stopped: acted === true };
|
|
120
|
+
}
|
|
121
|
+
const acted = d.queue && typeof d.queue.stopAll === 'function' ? d.queue.stopAll() : false;
|
|
122
|
+
return { status: 'accepted', stopped: acted === true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { handleSpeak, handleStop, isValidInstance, INSTANCE_ID_RE, TEXT_MAX };
|
package/lib/cli/doctor.js
CHANGED
|
@@ -15,6 +15,7 @@ const { fleetInstallPath } = require('./fleet-service.js');
|
|
|
15
15
|
const { resolvePaths } = require('./url.js');
|
|
16
16
|
const { commandExists } = require('./path.js');
|
|
17
17
|
const { loadDefinitions } = require('../fleet/definitions.js');
|
|
18
|
+
const { loadConfig } = require('../config.js');
|
|
18
19
|
const {
|
|
19
20
|
termuxRuntimePaths, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
|
|
20
21
|
} = require('../runtime/env.js');
|
|
@@ -426,6 +427,39 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
|
|
|
426
427
|
};
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
// Read-only check: alternate-screen is applied per managed session, while the
|
|
431
|
+
// history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
|
|
432
|
+
// or change a live server from doctor; only recommend a sufficient value.
|
|
433
|
+
function checkAlternateScreenHistory(alternateScreen, execImpl, tmuxBin) {
|
|
434
|
+
if (alternateScreen === true) {
|
|
435
|
+
return { name: 'Fleet alternate-screen history', ok: true, detail: 'alternate-screen standard attivo' };
|
|
436
|
+
}
|
|
437
|
+
let raw = '';
|
|
438
|
+
try {
|
|
439
|
+
raw = String(execImpl(tmuxBin || 'tmux', ['show-options', '-g', 'history-limit'], { encoding: 'utf8' }) || '');
|
|
440
|
+
} catch (_) {
|
|
441
|
+
return {
|
|
442
|
+
name: 'Fleet alternate-screen history', ok: true, warn: true,
|
|
443
|
+
detail: 'history-limit non verificabile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const match = raw.match(/(?:^|\n)history-limit\s+(\d+)\b/);
|
|
447
|
+
if (!match) {
|
|
448
|
+
return {
|
|
449
|
+
name: 'Fleet alternate-screen history', ok: true, warn: true,
|
|
450
|
+
detail: 'history-limit non leggibile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const limit = Number(match[1]);
|
|
454
|
+
if (limit < 10000) {
|
|
455
|
+
return {
|
|
456
|
+
name: 'Fleet alternate-screen history', ok: true, warn: true,
|
|
457
|
+
detail: `history-limit ${limit} (<10000) con alternateScreen off; configura set -g history-limit 100000 in ~/.tmux.conf`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return { name: 'Fleet alternate-screen history', ok: true, detail: `history-limit ${limit}` };
|
|
461
|
+
}
|
|
462
|
+
|
|
429
463
|
// Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
|
|
430
464
|
function doctor(opts = {}) {
|
|
431
465
|
const platform = opts.platform || detectPlatform();
|
|
@@ -454,6 +488,8 @@ function doctor(opts = {}) {
|
|
|
454
488
|
checkTmuxSurvival(platform, execImpl),
|
|
455
489
|
checkTokenPerms(tokenPath),
|
|
456
490
|
checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
|
|
491
|
+
checkAlternateScreenHistory(opts.alternateScreen !== undefined
|
|
492
|
+
? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
|
|
457
493
|
checkTermuxExec(opts.env, { platform, home }),
|
|
458
494
|
checkSshClient(existsImpl),
|
|
459
495
|
checkAutossh(existsImpl),
|
|
@@ -477,6 +513,7 @@ module.exports = {
|
|
|
477
513
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
478
514
|
checkMacServiceWorkingDirectory,
|
|
479
515
|
checkFleetDefinitions,
|
|
516
|
+
checkAlternateScreenHistory,
|
|
480
517
|
checkServiceWorkingDirectory,
|
|
481
518
|
checkFleetServiceWorkingDirectory,
|
|
482
519
|
checkTmuxSurvival, checkUserLinger,
|
package/lib/config.js
CHANGED
|
@@ -22,6 +22,9 @@ function baseDefaults() {
|
|
|
22
22
|
// Shared-server safety is operational hardening, not a same-UID security
|
|
23
23
|
// boundary. Disable only when the operator deliberately owns tmux policy.
|
|
24
24
|
protectSharedTmuxServer: true,
|
|
25
|
+
// Fleet sessions stay on the normal screen by default so their transcript
|
|
26
|
+
// enters tmux history and remains scrollable from the web terminal.
|
|
27
|
+
alternateScreen: false,
|
|
25
28
|
readonlyDefault: false,
|
|
26
29
|
// Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
|
|
27
30
|
replyLabel: 'human',
|
|
@@ -72,6 +75,10 @@ function envOverrides() {
|
|
|
72
75
|
e.protectSharedTmuxServer = !['', '0', 'false', 'no', 'off']
|
|
73
76
|
.includes(String(process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER).toLowerCase());
|
|
74
77
|
}
|
|
78
|
+
if (process.env.NEXUSCREW_ALTERNATE_SCREEN !== undefined) {
|
|
79
|
+
e.alternateScreen = !['', '0', 'false', 'no', 'off']
|
|
80
|
+
.includes(String(process.env.NEXUSCREW_ALTERNATE_SCREEN).toLowerCase());
|
|
81
|
+
}
|
|
75
82
|
if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
|
|
76
83
|
if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
|
|
77
84
|
if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -47,7 +47,7 @@ const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
|
|
|
47
47
|
// da builtin.js; httpError e' usato anche dal CRUD del facade.
|
|
48
48
|
const { createBuiltinRuntime } = require('./runtime.js');
|
|
49
49
|
const {
|
|
50
|
-
composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
|
|
50
|
+
composeLaunchArgv, composeClientInvocation, alternateScreenArgs, minimalEnv, promptCharsOk,
|
|
51
51
|
redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError, migrateLegacyTmuxSessions,
|
|
52
52
|
} = require('./launch.js');
|
|
53
53
|
|
|
@@ -876,6 +876,7 @@ module.exports = {
|
|
|
876
876
|
resolveCellCwd,
|
|
877
877
|
composeLaunchArgv,
|
|
878
878
|
composeClientInvocation,
|
|
879
|
+
alternateScreenArgs,
|
|
879
880
|
minimalEnv,
|
|
880
881
|
promptCharsOk,
|
|
881
882
|
redactSecrets,
|
package/lib/fleet/launch.js
CHANGED
|
@@ -309,6 +309,21 @@ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
|
309
309
|
return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
// Pure: applica alternate-screen soltanto alla sessione Fleet appena creata.
|
|
313
|
+
// Il target exact-match protegge da nomi che condividono un prefisso; `-w`
|
|
314
|
+
// mantiene l'opzione window-local e non muta mai il server tmux globale.
|
|
315
|
+
// La hook e' necessaria perche' le finestre create dopo new-session non
|
|
316
|
+
// ereditano l'opzione della prima finestra.
|
|
317
|
+
function alternateScreenArgs(session, alternateScreen = false) {
|
|
318
|
+
if (!isTmuxSafeName(session)) return null;
|
|
319
|
+
if (alternateScreen !== false) return [];
|
|
320
|
+
const target = `=${session}:`;
|
|
321
|
+
return [
|
|
322
|
+
['set-option', '-t', target, '-w', 'alternate-screen', 'off'],
|
|
323
|
+
['set-hook', '-t', target, 'after-new-window', 'set-option -w alternate-screen off'],
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
|
|
312
327
|
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
313
328
|
// e' viva entro la deadline, false altrimenti (command uscito / mai partita).
|
|
314
329
|
async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
@@ -380,6 +395,7 @@ module.exports = {
|
|
|
380
395
|
promptCharsOk,
|
|
381
396
|
composeClientInvocation,
|
|
382
397
|
composeLaunchArgv,
|
|
398
|
+
alternateScreenArgs,
|
|
383
399
|
migrateLegacyTmuxSessions,
|
|
384
400
|
waitAlive,
|
|
385
401
|
waitStablePane,
|