@mmmbuto/nexuscrew 0.9.40 → 0.9.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 +8 -1
- package/bin/nc-activity-hook.js +55 -0
- package/frontend/dist/assets/{index-Csz8VaFz.css → index-Ca1Rl40n.css} +1 -1
- package/frontend/dist/assets/index-DGa2FEN2.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/files/activity.js +294 -0
- package/lib/fleet/cell-exec.js +66 -3
- package/lib/fleet/managed.js +225 -30
- package/lib/fleet/mcp-cache-readiness.js +154 -0
- package/lib/fleet/prompt-delivery.js +65 -12
- package/lib/fleet/runtime.js +95 -9
- package/lib/notify/ask-relay.js +104 -19
- package/lib/server.js +13 -0
- package/package.json +4 -3
- package/frontend/dist/assets/index-BJBurY16.js +0 -169
package/lib/fleet/runtime.js
CHANGED
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
} = require('./definitions.js');
|
|
20
20
|
const {
|
|
21
21
|
describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
|
|
22
|
+
knownMcpServerNames,
|
|
22
23
|
} = require('./managed.js');
|
|
23
24
|
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
24
25
|
const {
|
|
@@ -28,6 +29,56 @@ const {
|
|
|
28
29
|
redactSecrets, sanitizeEarlyDiagnostic,
|
|
29
30
|
} = require('./launch.js');
|
|
30
31
|
const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
|
|
32
|
+
const { scriviGenerazione } = require('../files/activity.js');
|
|
33
|
+
|
|
34
|
+
// Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
|
|
35
|
+
// writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
|
|
36
|
+
// concesso non esiste per il launch, quindi non e' atteso dal gate.
|
|
37
|
+
const MCP_BUDGET_DEFAULT_MS = 20000; // tetto approvato per il gate MCP
|
|
38
|
+
|
|
39
|
+
function cellMcpExpectedServers(home, cellId) {
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
try {
|
|
42
|
+
const p = path.join(home, '.nexuscrew', 'cell-mcp', `${cellId}.json`);
|
|
43
|
+
const st = fs.lstatSync(p);
|
|
44
|
+
if (st.isSymbolicLink() || !st.isFile()) return [];
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
46
|
+
const servers = parsed && typeof parsed === 'object' && parsed.mcpServers
|
|
47
|
+
&& typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
|
|
48
|
+
? parsed.mcpServers : null;
|
|
49
|
+
return servers ? Object.keys(servers).sort() : [];
|
|
50
|
+
} catch (_) { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Celle claude LEGACY (nessun materializzato cell-mcp): l'atteso e' la config
|
|
54
|
+
// nota al client (le tre sorgenti di `knownMcpServerNames`) meno cio' che la
|
|
55
|
+
// cella nega con `cell.mcp`. RESIDUO DICHIARATO: plugin, connettori e
|
|
56
|
+
// configurazione gestita di sistema non sono enumerabili e restano fuori
|
|
57
|
+
// dall'atteso — il gate copre cio' che e' enumerabile, mai promette il totale.
|
|
58
|
+
function legacyMcpExpectedServers(home, cwd, cell) {
|
|
59
|
+
const noti = [...(knownMcpServerNames(home, cwd) || [])];
|
|
60
|
+
if (!noti.length) return [];
|
|
61
|
+
const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
|
|
62
|
+
if (voluti === null) return [...noti].sort();
|
|
63
|
+
const concessi = new Set(voluti.filter((n) => typeof n === 'string'));
|
|
64
|
+
return [...noti].filter((n) => concessi.has(n)).sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Payload readiness per il supervisore: cwd reale della cella + attese + tetto
|
|
68
|
+
// MCP. Priorita' all'insieme MATERIALIZZATO (cell-mcp); per le claude legacy
|
|
69
|
+
// ricade sulla config nota meno i negati. undefined quando la cella non ha
|
|
70
|
+
// server attesi (niente gate: consegna come kimi, che da 0.8.47 non ha gate).
|
|
71
|
+
function buildMcpReadinessPayload(cell, realCwd, home, cfg) {
|
|
72
|
+
const expectedServers = cellMcpExpectedServers(home, cell.id).length
|
|
73
|
+
? cellMcpExpectedServers(home, cell.id)
|
|
74
|
+
: legacyMcpExpectedServers(home, realCwd, cell);
|
|
75
|
+
if (!expectedServers.length) return undefined;
|
|
76
|
+
return {
|
|
77
|
+
cwd: realCwd,
|
|
78
|
+
expectedServers,
|
|
79
|
+
budgetMs: Math.max(0, Math.min(120000, Number(cfg && cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
31
82
|
|
|
32
83
|
// TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
|
|
33
84
|
const STATUS_TTL_MS = 2000;
|
|
@@ -241,7 +292,16 @@ function createBuiltinRuntime(ctx) {
|
|
|
241
292
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
242
293
|
let launchEngine = engine;
|
|
243
294
|
if (engine.managed) {
|
|
244
|
-
|
|
295
|
+
// Generazione del lancio: identifica QUESTA partenza della cella. Uno
|
|
296
|
+
// stato di attivita' che ne porta un'altra appartiene a un lancio
|
|
297
|
+
// precedente e viene scartato — una cella riavviata non eredita il
|
|
298
|
+
// «lavora» di prima.
|
|
299
|
+
const generazione = crypto.randomBytes(8).toString('hex');
|
|
300
|
+
const resolved = resolveManagedEngine(engine, cell, {
|
|
301
|
+
...cfg, home, extraModels: extraModelsFrom(defs),
|
|
302
|
+
capabilityProfiles: defs.capabilityProfiles || null,
|
|
303
|
+
activityGeneration: generazione,
|
|
304
|
+
});
|
|
245
305
|
// Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
|
|
246
306
|
// non si scrive, la cella non parte — mai con la superficie intera dove
|
|
247
307
|
// l'operatore l'ha ristretta.
|
|
@@ -253,6 +313,11 @@ function createBuiltinRuntime(ctx) {
|
|
|
253
313
|
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
|
254
314
|
}
|
|
255
315
|
launchEngine = resolved.engine;
|
|
316
|
+
// La generazione si scrive PRIMA che il client parta: gli hook possono
|
|
317
|
+
// scattare appena il processo e' vivo, e uno stato che arrivasse prima
|
|
318
|
+
// della generazione verrebbe scartato come se fosse di un lancio
|
|
319
|
+
// precedente. Solo per le celle che hanno davvero gli hook.
|
|
320
|
+
if (resolved.activityDir) scriviGenerazione(resolved.activityDir, generazione);
|
|
256
321
|
// A vl cell whose runtime cannot hold the per-cell prompt
|
|
257
322
|
// (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
|
|
258
323
|
// shape as readinessDegraded: the cell starts without its own identity
|
|
@@ -342,12 +407,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
342
407
|
tmuxSession: cell.tmuxSession,
|
|
343
408
|
prompt: cell.prompt,
|
|
344
409
|
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
client: engine.managed && engine.managed.client === 'kimi'
|
|
349
|
-
|
|
410
|
+
// kimi.* e TUTTE le claude.* hanno la delivery classificata
|
|
411
|
+
// at-most-once ai restart; gli engine custom send-keys conservano
|
|
412
|
+
// il reinject legacy senza Enter (0.8.47).
|
|
413
|
+
client: engine.managed && (engine.managed.client === 'kimi' || engine.managed.client === 'claude')
|
|
414
|
+
? engine.managed.client : '',
|
|
350
415
|
readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
|
|
416
|
+
// Gate readiness MCP solo per le claude.* (budget composto col
|
|
417
|
+
// readyWaitMs: tetto MCP dal launch, avvio degradato visibile).
|
|
418
|
+
...(engine.managed && engine.managed.client === 'claude' ? {
|
|
419
|
+
mcpReadiness: buildMcpReadinessPayload(cell, realCwd, home, cfg),
|
|
420
|
+
} : {}),
|
|
351
421
|
},
|
|
352
422
|
} : {}),
|
|
353
423
|
});
|
|
@@ -557,14 +627,20 @@ function createBuiltinRuntime(ctx) {
|
|
|
557
627
|
// Paste SENZA Enter, contratto invariato —), target %N esatto.
|
|
558
628
|
let prompt = null;
|
|
559
629
|
let actionRequired = null;
|
|
630
|
+
let mcpDegraded = null;
|
|
560
631
|
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
561
632
|
const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
|
|
562
633
|
const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
|
|
563
|
-
const classified = managedClient === 'kimi' ||
|
|
634
|
+
const classified = managedClient === 'kimi' || managedClient === 'claude';
|
|
564
635
|
if (classified) {
|
|
565
636
|
const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
|
|
637
|
+
// Budget unico composto — il report copre anche il tetto MCP del
|
|
638
|
+
// gate (l'attesa MCP consuma lo stesso orologio del launch).
|
|
639
|
+
const mcpBudgetMs = managedClient === 'claude'
|
|
640
|
+
? Math.max(0, Math.min(120000, Number(cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS))
|
|
641
|
+
: 0;
|
|
566
642
|
const reportWaitMs = Math.max(100, Math.min(150000,
|
|
567
|
-
(Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
|
|
643
|
+
(Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + mcpBudgetMs + 2000));
|
|
568
644
|
const report = await waitDeliveryReport(tmuxBin,
|
|
569
645
|
paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
|
|
570
646
|
{ env: minimalEnv(), timeoutMs: reportWaitMs });
|
|
@@ -576,8 +652,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
576
652
|
delivered: delivery.delivered,
|
|
577
653
|
state: delivery.state,
|
|
578
654
|
reason: delivery.reason,
|
|
655
|
+
...(delivery.mcp ? { mcp: delivery.mcp } : {}),
|
|
579
656
|
};
|
|
580
657
|
actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
|
|
658
|
+
// Avvio degradato visibile: l'elenco bounded dei server MCP non pronti
|
|
659
|
+
// sale alla UI con il flag esistente readinessDegraded.
|
|
660
|
+
if (delivery.mcp && delivery.mcp.state === 'degraded') {
|
|
661
|
+
mcpDegraded = {
|
|
662
|
+
failed: Array.isArray(delivery.mcp.failed) ? delivery.mcp.failed : [],
|
|
663
|
+
pending: Array.isArray(delivery.mcp.pending) ? delivery.mcp.pending : [],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
581
666
|
} else {
|
|
582
667
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
583
668
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
@@ -593,6 +678,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
593
678
|
ok: true, cell: cellId, session: cell.tmuxSession, prompt,
|
|
594
679
|
...(actionRequired ? { actionRequired } : {}),
|
|
595
680
|
...(readinessDegraded ? { readinessDegraded: true } : {}),
|
|
681
|
+
...(mcpDegraded ? { readinessDegraded: true, mcpDegraded } : {}),
|
|
596
682
|
// Per-cell prompt not delivered to a vl cell (old runtime or
|
|
597
683
|
// unwritable file). Strict boolean like readinessDegraded: the
|
|
598
684
|
// payload may come from a federated node and the text is local i18n.
|
|
@@ -630,4 +716,4 @@ function createBuiltinRuntime(ctx) {
|
|
|
630
716
|
};
|
|
631
717
|
}
|
|
632
718
|
|
|
633
|
-
module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS };
|
|
719
|
+
module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS, buildMcpReadinessPayload, cellMcpExpectedServers };
|
package/lib/notify/ask-relay.js
CHANGED
|
@@ -14,23 +14,66 @@
|
|
|
14
14
|
// refused locally until verification resolves the attempt.
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto');
|
|
17
|
+
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
17
18
|
const IDLE_MS = 15000;
|
|
18
19
|
|
|
19
|
-
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
|
|
20
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
|
|
20
21
|
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
21
22
|
const attempts = new Map();
|
|
22
23
|
|
|
23
24
|
function requestId() { return crypto.randomUUID(); }
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// Risoluzione del produttore: peer OUTBOUND (canale proprio, come sempre)
|
|
27
|
+
// oppure peer INBOUND condiviso con consenso eventi+risposta e slot reverse
|
|
28
|
+
// attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
|
|
29
|
+
// stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
|
|
30
|
+
// fallback sulla localPort senza proof.
|
|
31
|
+
function candidateOwner(ownerId) {
|
|
26
32
|
const store = loadStore();
|
|
27
33
|
if (!store) return null;
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
const nodes = store.nodes || [];
|
|
35
|
+
const out = nodes.find((n) => n && n.direction === 'outbound'
|
|
36
|
+
&& n.nodeId === ownerId && n.token && n.localPort);
|
|
37
|
+
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
|
|
38
|
+
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
39
|
+
&& n.shared === true && n.nodeId === ownerId
|
|
40
|
+
&& (n.token || n.acceptToken)
|
|
41
|
+
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
42
|
+
&& n.cellVisibility !== 'none');
|
|
43
|
+
if (!inbound) return null;
|
|
44
|
+
const forwardToken = inbound.token || inbound.acceptToken;
|
|
45
|
+
const pool = inbound.reversePool;
|
|
46
|
+
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
47
|
+
if (!slot || !Number.isInteger(slot.port)) {
|
|
48
|
+
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
|
|
49
|
+
}
|
|
50
|
+
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownerUrl(port, path) {
|
|
54
|
+
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
// Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
|
|
58
|
+
// non e' verificato (non e' un esito incerto: il forward non e' mai partito).
|
|
59
|
+
async function verifyInboundChannel(candidate) {
|
|
60
|
+
const { node, forwardPort } = candidate;
|
|
61
|
+
const pool = node.reversePool;
|
|
62
|
+
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
63
|
+
const proof = await probeReverseSlotImpl({
|
|
64
|
+
port: forwardPort, secret: node.token,
|
|
65
|
+
expected: {
|
|
66
|
+
remotePort: forwardPort,
|
|
67
|
+
generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
|
|
68
|
+
instanceId: node.nodeId,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (proof && proof.owned === true) return { ok: true };
|
|
72
|
+
return {
|
|
73
|
+
ok: false, code: 404,
|
|
74
|
+
error: 'canale reverse non verificato: risposta non inoltrata',
|
|
75
|
+
reason: 'reverse-slot-unverified',
|
|
76
|
+
};
|
|
34
77
|
}
|
|
35
78
|
|
|
36
79
|
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
@@ -47,16 +90,35 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
47
90
|
return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
|
|
48
91
|
}
|
|
49
92
|
|
|
93
|
+
// Il blocco precedente rifiuta il nuovo requestId PRIMA di creare la sua
|
|
94
|
+
// ricevuta: la sola verifica possibile e' sull'ID originale ancora 'sent'
|
|
95
|
+
// (se esiste), altrimenti resta solo la riconciliazione con l'owner.
|
|
96
|
+
function originalSentRequestId(ownerId, askId, excludeRid) {
|
|
97
|
+
const sent = statusFor(ownerId, askId)
|
|
98
|
+
.filter((a) => (a.state === 'sent' || a.state === 'uncertain') && a.requestId !== excludeRid);
|
|
99
|
+
return sent.length ? sent[sent.length - 1].requestId : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
50
102
|
async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
|
|
51
103
|
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
52
104
|
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
53
105
|
}
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
106
|
+
const candidate = candidateOwner(ownerId);
|
|
107
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
108
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
109
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
110
|
+
}
|
|
111
|
+
if (candidate.kind === 'inbound') {
|
|
112
|
+
const gate = await verifyInboundChannel(candidate);
|
|
113
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
114
|
+
}
|
|
115
|
+
const peer = candidate.node;
|
|
56
116
|
// Local reconciliation FIRST: an uncertain attempt on this ask must be
|
|
57
117
|
// verified before any new paste leaves this node.
|
|
58
118
|
if (isUncertain(ownerId, askId)) {
|
|
59
|
-
|
|
119
|
+
// L'ID originale e' quello verificabile: il nuovo invio non e' mai partito.
|
|
120
|
+
return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true,
|
|
121
|
+
originalRequestId: originalSentRequestId(ownerId, askId) };
|
|
60
122
|
}
|
|
61
123
|
const rid = providedRid || requestId();
|
|
62
124
|
const key = attemptKey(ownerId, askId, rid);
|
|
@@ -65,10 +127,10 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
65
127
|
const ctrl = new AbortController();
|
|
66
128
|
const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
|
|
67
129
|
try {
|
|
68
|
-
const r = await fetchImpl(ownerUrl(
|
|
130
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
|
|
69
131
|
method: 'POST',
|
|
70
132
|
headers: {
|
|
71
|
-
authorization: `Bearer ${
|
|
133
|
+
authorization: `Bearer ${candidate.forwardToken}`,
|
|
72
134
|
'content-type': 'application/json',
|
|
73
135
|
'x-nexuscrew-visited': store.nodeId,
|
|
74
136
|
},
|
|
@@ -86,6 +148,13 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
86
148
|
// could have pasted (timeout-ish) parks as uncertain, never retried.
|
|
87
149
|
if (r.status === 409) {
|
|
88
150
|
if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
|
|
151
|
+
if (body.reason === 'delivery-unknown-block') {
|
|
152
|
+
// Stesso contratto del ramo 502: la UI riceve 200+uncertain e non
|
|
153
|
+
// ripete mai il paste. La verifica possibile e' sull'ID originale.
|
|
154
|
+
return { ok: false, code: 200, uncertain: true, reason: body.reason,
|
|
155
|
+
originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
|
|
156
|
+
error: 'esito incerto: bloccato da un invio precedente' };
|
|
157
|
+
}
|
|
89
158
|
return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
|
|
90
159
|
}
|
|
91
160
|
if (r.status === 502) {
|
|
@@ -108,13 +177,21 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
108
177
|
|
|
109
178
|
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
110
179
|
async function relayDismiss({ ownerId, askId } = {}) {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
180
|
+
const candidate = candidateOwner(ownerId);
|
|
181
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
182
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
183
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
184
|
+
}
|
|
185
|
+
if (candidate.kind === 'inbound') {
|
|
186
|
+
const gate = await verifyInboundChannel(candidate);
|
|
187
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
188
|
+
}
|
|
189
|
+
const peer = candidate.node;
|
|
113
190
|
const store = loadStore();
|
|
114
191
|
try {
|
|
115
|
-
const r = await fetchImpl(ownerUrl(
|
|
192
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
|
|
116
193
|
method: 'DELETE',
|
|
117
|
-
headers: { authorization: `Bearer ${
|
|
194
|
+
headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
118
195
|
});
|
|
119
196
|
const body = await r.json().catch(() => ({}));
|
|
120
197
|
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
@@ -123,12 +200,20 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
123
200
|
}
|
|
124
201
|
|
|
125
202
|
async function verifyStatus({ ownerId, askId, requestId: rid }) {
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
203
|
+
const candidate = candidateOwner(ownerId);
|
|
204
|
+
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
205
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
206
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
207
|
+
}
|
|
208
|
+
if (candidate.kind === 'inbound') {
|
|
209
|
+
const gate = await verifyInboundChannel(candidate);
|
|
210
|
+
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
211
|
+
}
|
|
212
|
+
const peer = candidate.node;
|
|
128
213
|
const store = loadStore();
|
|
129
214
|
try {
|
|
130
|
-
const r = await fetchImpl(ownerUrl(
|
|
131
|
-
headers: { authorization: `Bearer ${
|
|
215
|
+
const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
|
|
216
|
+
headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
132
217
|
});
|
|
133
218
|
if (!r.ok) return { ok: false, code: r.status };
|
|
134
219
|
const body = await r.json();
|
package/lib/server.js
CHANGED
|
@@ -20,6 +20,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
|
20
20
|
const { filesRoutes } = require('./files/routes.js');
|
|
21
21
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
22
22
|
const { leggiTelemetria } = require('./files/telemetry.js');
|
|
23
|
+
const { leggiAttivita } = require('./files/activity.js');
|
|
23
24
|
const VERSION = require('../package.json').version;
|
|
24
25
|
const { transcribe } = require('./voice/transcribe.js');
|
|
25
26
|
const { selectProvider } = require('./fleet/provider.js');
|
|
@@ -993,6 +994,12 @@ function createServer(opts = {}) {
|
|
|
993
994
|
// Claude via statusline). Null per tutte le altre — assenza
|
|
994
995
|
// legittima, non un trattino: la riga resta com'era.
|
|
995
996
|
telemetry: leggiTelemetria(cfg.filesRoot, s.name),
|
|
997
|
+
// Stato del turno pubblicato dagli hook della cella: lavora /
|
|
998
|
+
// ferma / attesa. `null` NON e' «ferma»: e' «non verificato» —
|
|
999
|
+
// nessun evento entro la finestra, oppure una cella che non ha questo
|
|
1000
|
+
// canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
|
|
1001
|
+
// silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
|
|
1002
|
+
attivita: leggiAttivita(cfg.filesRoot, s.name),
|
|
996
1003
|
};
|
|
997
1004
|
}));
|
|
998
1005
|
res.json({ sessions: enriched });
|
|
@@ -1272,6 +1279,12 @@ function createServer(opts = {}) {
|
|
|
1272
1279
|
});
|
|
1273
1280
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1274
1281
|
});
|
|
1282
|
+
// Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
|
|
1283
|
+
// contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
|
|
1284
|
+
api.get('/asks-relay/state', (_req, res) => {
|
|
1285
|
+
try { res.json({ attempts: askRelay.state() }); }
|
|
1286
|
+
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1287
|
+
});
|
|
1275
1288
|
// The client re-emits imported events into the local hub (deliverOnly: the
|
|
1276
1289
|
// notifier is bypassed on purpose). The alert relay hangs off that same call,
|
|
1277
1290
|
// so ONLY an imported notify can ring this device, and never through the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.41",
|
|
4
4
|
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,15 +45,16 @@
|
|
|
45
45
|
"express": "^4.21.0",
|
|
46
46
|
"multer": "^2.2.0",
|
|
47
47
|
"qrcode-terminal": "^0.12.0",
|
|
48
|
+
"smol-toml": "1.9.0",
|
|
48
49
|
"web-push": "^3.6.7",
|
|
49
50
|
"ws": "^8.18.0"
|
|
50
51
|
},
|
|
51
52
|
"optionalDependencies": {
|
|
52
|
-
"@mmmbuto/node-pty-android-arm64": "^1.1.2",
|
|
53
53
|
"@lydell/node-pty-darwin-arm64": "^1.2.0-beta.14",
|
|
54
54
|
"@lydell/node-pty-darwin-x64": "^1.2.0-beta.14",
|
|
55
55
|
"@lydell/node-pty-linux-arm64": "^1.2.0-beta.12",
|
|
56
|
-
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
|
|
56
|
+
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12",
|
|
57
|
+
"@mmmbuto/node-pty-android-arm64": "^1.1.2"
|
|
57
58
|
},
|
|
58
59
|
"engines": {
|
|
59
60
|
"node": ">=18"
|