nexus-channel 1.7.6 → 1.7.7

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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getEventTimestamp = getEventTimestamp;
4
4
  exports.getEventDecision = getEventDecision;
5
5
  exports.buildEventPrompt = buildEventPrompt;
6
+ exports.parseSessionDirectives = parseSessionDirectives;
6
7
  exports.processEvent = processEvent;
7
8
  const schema_1 = require("../config/schema");
8
9
  const chat_client_1 = require("../gateway/chat-client");
@@ -53,6 +54,33 @@ function buildEventPrompt(event, config) {
53
54
  }
54
55
  return `[from: ${from}] ${content}`;
55
56
  }
57
+ // ── Orchestrator Session Key Parsing ──────────────────────────────────────
58
+ /**
59
+ * Extract and strip orchestrator session directives from event.text.
60
+ *
61
+ * hub2d injects these prefixes for orchestrated tasks:
62
+ * [SESSION_KEY:nexus:roomId:e:planId] — use this session key instead of room default
63
+ * [SESSION_RESET] — send /new to OpenClaw before dispatching
64
+ *
65
+ * Returns { text (cleaned), sessionKey (if overridden), sessionReset (boolean) }
66
+ */
67
+ function parseSessionDirectives(rawText) {
68
+ let text = rawText;
69
+ let sessionKey = null;
70
+ let sessionReset = false;
71
+ // Extract [SESSION_KEY:xxx]
72
+ const keyMatch = text.match(/\[SESSION_KEY:([^\]]+)\]/);
73
+ if (keyMatch) {
74
+ sessionKey = keyMatch[1];
75
+ text = text.replace(/\[SESSION_KEY:[^\]]+\]\n?/, '');
76
+ }
77
+ // Extract [SESSION_RESET]
78
+ if (text.includes('[SESSION_RESET]')) {
79
+ sessionReset = true;
80
+ text = text.replace(/\[SESSION_RESET\]\n?/, '');
81
+ }
82
+ return { text: text.trim(), sessionKey, sessionReset };
83
+ }
56
84
  async function processEvent(params) {
57
85
  const { event, config, selfUserId, isRecent, markRecent, gatewayConfig, sendReply, sendAck, updateResumeToken } = params;
58
86
  const decision = getEventDecision({ event, config, selfUserId, isRecent });
@@ -66,13 +94,32 @@ async function processEvent(params) {
66
94
  }
67
95
  markRecent(event.event_id);
68
96
  try {
69
- let prompt = buildEventPrompt(event, config);
97
+ // Parse orchestrator session directives from event.text
98
+ const { text: cleanedText, sessionKey: overrideKey, sessionReset } = parseSessionDirectives(event.text || '');
99
+ // Build event with cleaned text (orchestrator prefixes stripped)
100
+ const cleanedEvent = { ...event, text: cleanedText };
101
+ let prompt = buildEventPrompt(cleanedEvent, config);
70
102
  const hub2dUrl = config.hub2dUrl || 'ws://127.0.0.1:3001';
71
103
  const context = await (0, context_1.fetchNexusContext)(roomId, hub2dUrl, config);
72
104
  if (context) {
73
105
  prompt = `${context}${prompt}`;
74
106
  }
75
- const sessionKey = `nexus:${roomId}`;
107
+ // Use overridden session key from orchestrator, or default room session
108
+ const sessionKey = overrideKey || `nexus:${roomId}`;
109
+ // If session reset requested, send /new to gateway first to clear conversation history
110
+ if (sessionReset) {
111
+ try {
112
+ await (0, chat_client_1.callGatewayWithRetry)([{ role: 'user', content: '/new' }], sessionKey, gatewayConfig);
113
+ console.log(`[nexus] Session reset: key=${sessionKey}`);
114
+ }
115
+ catch (resetErr) {
116
+ console.warn(`[nexus] Session reset failed for ${sessionKey}:`, resetErr);
117
+ }
118
+ // Early return: session reset events should not generate a reply
119
+ sendAck(roomId, event.event_id);
120
+ updateResumeToken(roomId, event.event_id);
121
+ return 'skip-reset';
122
+ }
76
123
  const response = await (0, chat_client_1.callGatewayWithRetry)([{ role: 'user', content: prompt }], sessionKey, gatewayConfig);
77
124
  sendReply(event.event_id, roomId, event.from || 'unknown', response.content, 'done');
78
125
  sendAck(roomId, event.event_id);
@@ -43,10 +43,19 @@ const path = __importStar(require("path"));
43
43
  const frames_1 = require("./frames");
44
44
  const reconnect_1 = require("./reconnect");
45
45
  const resume_store_1 = require("./resume-store");
46
+ const MAX_OUTBOX_SIZE = 100;
47
+ // ── Client-side liveness ───────────────────────────────────────────────────
48
+ const IDLE_TIMEOUT_MS = 90000; // 90 seconds — if no frame received, connection is dead
49
+ const IDLE_CHECK_INTERVAL_MS = 30000; // check every 30 seconds
46
50
  class WSClient {
47
51
  constructor(config) {
48
52
  this.ws = null;
49
53
  this.reconnectTimer = null;
54
+ // Client-side liveness tracking
55
+ this.lastFrameAt = 0;
56
+ this.idleCheckTimer = null;
57
+ // Outbound message queue
58
+ this.outbox = [];
50
59
  this.config = config;
51
60
  this.reconnectState = (0, reconnect_1.createReconnectState)();
52
61
  this.state = { connected: false };
@@ -60,12 +69,15 @@ class WSClient {
60
69
  catch { }
61
70
  this.ws = null;
62
71
  }
72
+ // Stop previous idle check
73
+ this.stopIdleCheck();
63
74
  console.log(`[nexus] WS connecting: ${this.config.hub2dUrl} rooms=${this.config.rooms.join(',')}`);
64
75
  this.ws = new ws_1.default(this.config.hub2dUrl);
65
76
  this.ws.on('open', () => {
66
77
  this.sendConnectFrame();
67
78
  });
68
79
  this.ws.on('message', (data) => {
80
+ this.lastFrameAt = Date.now();
69
81
  this.handleMessage(data);
70
82
  });
71
83
  this.ws.on('close', (code, reason) => {
@@ -74,6 +86,9 @@ class WSClient {
74
86
  this.ws.on('error', (error) => {
75
87
  console.error(`[nexus] WS error: ${error.message}`);
76
88
  });
89
+ // Start idle detection
90
+ this.lastFrameAt = Date.now();
91
+ this.startIdleCheck();
77
92
  }
78
93
  sendConnectFrame() {
79
94
  const frame = (0, frames_1.buildConnectFrame)({
@@ -108,6 +123,61 @@ class WSClient {
108
123
  return 'unknown';
109
124
  }
110
125
  }
126
+ // ── Idle detection (client-side liveness) ──────────────────────────────
127
+ startIdleCheck() {
128
+ this.stopIdleCheck();
129
+ this.idleCheckTimer = setInterval(() => {
130
+ if (!this.state.connected || !this.ws)
131
+ return;
132
+ const idle = Date.now() - this.lastFrameAt;
133
+ if (idle > IDLE_TIMEOUT_MS) {
134
+ console.warn(`[nexus] No frames received for ${Math.round(idle / 1000)}s, terminating stale connection`);
135
+ try {
136
+ this.ws.terminate();
137
+ }
138
+ catch {
139
+ // terminate triggers the close handler, which handles reconnect
140
+ }
141
+ }
142
+ }, IDLE_CHECK_INTERVAL_MS);
143
+ }
144
+ stopIdleCheck() {
145
+ if (this.idleCheckTimer) {
146
+ clearInterval(this.idleCheckTimer);
147
+ this.idleCheckTimer = null;
148
+ }
149
+ }
150
+ // ── Outbox (send queue for offline buffering) ─────────────────────────
151
+ enqueueOutbox(type, frame) {
152
+ const item = {
153
+ frame: JSON.stringify(frame),
154
+ type,
155
+ enqueuedAt: Date.now(),
156
+ };
157
+ this.outbox.push(item);
158
+ if (this.outbox.length > MAX_OUTBOX_SIZE) {
159
+ const dropped = this.outbox.shift();
160
+ console.warn(`[nexus] Outbox full, dropping oldest ${dropped?.type} (age ${Math.round((Date.now() - (dropped?.enqueuedAt || 0)) / 1000)}s)`);
161
+ }
162
+ }
163
+ flushOutbox() {
164
+ if (this.outbox.length === 0)
165
+ return;
166
+ const items = this.outbox.splice(0);
167
+ console.log(`[nexus] Flushing outbox: ${items.length} queued messages`);
168
+ for (const item of items) {
169
+ if (this.ws?.readyState === ws_1.default.OPEN) {
170
+ this.ws.send(item.frame);
171
+ }
172
+ else {
173
+ // Connection dropped again during flush — re-enqueue remaining
174
+ this.outbox.push(item);
175
+ console.warn(`[nexus] WS closed during outbox flush, ${this.outbox.length} items re-queued`);
176
+ return;
177
+ }
178
+ }
179
+ }
180
+ // ── Message handling ──────────────────────────────────────────────────
111
181
  handleMessage(data) {
112
182
  let frame;
113
183
  try {
@@ -127,6 +197,8 @@ class WSClient {
127
197
  this.state.userId = connectedFrame.user_id;
128
198
  }
129
199
  console.log(`[nexus] WS connected: user_id=${this.state.userId} backlog_count=${JSON.stringify(connectedFrame.backlog_count || {})}`);
200
+ // Flush any messages queued during the disconnect
201
+ this.flushOutbox();
130
202
  this.config.onConnected?.(connectedFrame);
131
203
  break;
132
204
  }
@@ -160,7 +232,14 @@ class WSClient {
160
232
  handleClose(code, reason, myConnId) {
161
233
  console.log(`[nexus] WS closed: code=${code} reason=${reason.toString() || 'none'} connId=${myConnId}/${this.reconnectState.connId}`);
162
234
  this.state.connected = false;
235
+ this.stopIdleCheck();
163
236
  (0, resume_store_1.flushResumeTokens)();
237
+ // Phase 3.3: Reset pendingApproval unless the close was due to pending approval
238
+ // (code 4003 is used by hub2d for PENDING_APPROVAL rejections)
239
+ if (this.state.pendingApproval && code !== 4003) {
240
+ this.state.pendingApproval = false;
241
+ (0, reconnect_1.resetReconnectDelay)(this.reconnectState);
242
+ }
164
243
  if (!(0, reconnect_1.isStaleConnection)(this.reconnectState, myConnId)) {
165
244
  this.scheduleReconnect();
166
245
  }
@@ -191,35 +270,45 @@ class WSClient {
191
270
  }
192
271
  }
193
272
  sendAck(roomId, eventId) {
194
- if (!this.ws || this.ws.readyState !== ws_1.default.OPEN)
195
- return;
196
- this.ws.send(JSON.stringify((0, frames_1.buildAckFrame)(roomId, eventId)));
273
+ const frame = (0, frames_1.buildAckFrame)(roomId, eventId);
274
+ if (this.ws?.readyState === ws_1.default.OPEN) {
275
+ this.ws.send(JSON.stringify(frame));
276
+ }
277
+ else {
278
+ this.enqueueOutbox('ack', frame);
279
+ }
197
280
  }
198
281
  sendReply(eventId, roomId, to, text, status) {
199
- if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
200
- console.error(`[nexus] Cannot send reply, WS not open (event_id=${eventId})`);
201
- return;
202
- }
203
- this.ws.send(JSON.stringify((0, frames_1.buildReplyFrame)({
282
+ const frame = (0, frames_1.buildReplyFrame)({
204
283
  eventId,
205
284
  roomId,
206
285
  from: this.config.agentName,
207
286
  to,
208
287
  text,
209
288
  status,
210
- })));
289
+ });
290
+ if (this.ws?.readyState === ws_1.default.OPEN) {
291
+ this.ws.send(JSON.stringify(frame));
292
+ }
293
+ else {
294
+ this.enqueueOutbox('reply', frame);
295
+ console.warn(`[nexus] WS not open, queued reply (event_id=${eventId})`);
296
+ }
211
297
  }
212
298
  sendMessage(roomId, text, mentions, clientMsgId) {
213
- if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
214
- console.error('[nexus] Cannot send message, WS not connected');
215
- return;
216
- }
217
- this.ws.send(JSON.stringify((0, frames_1.buildSendFrame)({
299
+ const frame = (0, frames_1.buildSendFrame)({
218
300
  roomId,
219
301
  text,
220
302
  mentions,
221
303
  clientMsgId,
222
- })));
304
+ });
305
+ if (this.ws?.readyState === ws_1.default.OPEN) {
306
+ this.ws.send(JSON.stringify(frame));
307
+ }
308
+ else {
309
+ this.enqueueOutbox('send', frame);
310
+ console.warn('[nexus] WS not open, queued message');
311
+ }
223
312
  }
224
313
  getState() {
225
314
  return { ...this.state };
@@ -230,11 +319,15 @@ class WSClient {
230
319
  getUserId() {
231
320
  return this.state.userId;
232
321
  }
322
+ getOutboxSize() {
323
+ return this.outbox.length;
324
+ }
233
325
  close() {
234
326
  if (this.reconnectTimer) {
235
327
  clearTimeout(this.reconnectTimer);
236
328
  this.reconnectTimer = null;
237
329
  }
330
+ this.stopIdleCheck();
238
331
  if (this.ws) {
239
332
  try {
240
333
  this.ws.close();
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_test_1 = require("node:test");
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ /**
9
+ * Unit tests for WSClient outbox queue and idle detection features.
10
+ *
11
+ * These test the data structures and logic without actual WebSocket connections.
12
+ */
13
+ // ── Outbox queue logic ─────────────────────────────────────────────────
14
+ (0, node_test_1.describe)('outbox queue', () => {
15
+ const MAX_OUTBOX_SIZE = 100;
16
+ function createOutbox() {
17
+ return [];
18
+ }
19
+ function enqueue(outbox, type, frame) {
20
+ const item = {
21
+ frame: JSON.stringify(frame),
22
+ type,
23
+ enqueuedAt: Date.now(),
24
+ };
25
+ outbox.push(item);
26
+ if (outbox.length > MAX_OUTBOX_SIZE) {
27
+ const dropped = outbox.shift();
28
+ console.warn(`Outbox full, dropping oldest ${dropped?.type}`);
29
+ }
30
+ return outbox;
31
+ }
32
+ (0, node_test_1.it)('queues items when WS is not connected', () => {
33
+ const outbox = createOutbox();
34
+ enqueue(outbox, 'reply', { type: 'reply', text: 'hello' });
35
+ enqueue(outbox, 'ack', { type: 'ack', room_id: 'general', event_id: '123' });
36
+ strict_1.default.equal(outbox.length, 2);
37
+ strict_1.default.equal(outbox[0].type, 'reply');
38
+ strict_1.default.equal(outbox[1].type, 'ack');
39
+ });
40
+ (0, node_test_1.it)('drops oldest when queue exceeds max size', () => {
41
+ const outbox = createOutbox();
42
+ // Fill beyond max
43
+ for (let i = 0; i < MAX_OUTBOX_SIZE + 10; i++) {
44
+ enqueue(outbox, 'reply', { type: 'reply', text: `msg-${i}` });
45
+ }
46
+ strict_1.default.equal(outbox.length, MAX_OUTBOX_SIZE);
47
+ // First item should be msg-10 (first 10 were dropped)
48
+ const first = JSON.parse(outbox[0].frame);
49
+ strict_1.default.equal(first.text, 'msg-10');
50
+ });
51
+ (0, node_test_1.it)('flush drains the queue', () => {
52
+ const outbox = createOutbox();
53
+ enqueue(outbox, 'reply', { type: 'reply', text: 'hello' });
54
+ enqueue(outbox, 'ack', { type: 'ack', room_id: 'general', event_id: '123' });
55
+ const flushed = outbox.splice(0);
56
+ strict_1.default.equal(flushed.length, 2);
57
+ strict_1.default.equal(outbox.length, 0);
58
+ });
59
+ (0, node_test_1.it)('preserves frame serialization', () => {
60
+ const outbox = createOutbox();
61
+ const original = { type: 'reply', event_id: '123-0', room_id: 'general', from: 'cortana', to: 'boss', text: 'test reply', status: 'done' };
62
+ enqueue(outbox, 'reply', original);
63
+ const parsed = JSON.parse(outbox[0].frame);
64
+ strict_1.default.deepEqual(parsed, original);
65
+ });
66
+ });
67
+ // ── Idle detection logic ──────────────────────────────────────────────
68
+ (0, node_test_1.describe)('idle detection', () => {
69
+ const IDLE_TIMEOUT_MS = 90000;
70
+ (0, node_test_1.it)('detects stale connection when no frames received', () => {
71
+ const lastFrameAt = Date.now() - 100000; // 100s ago, over 90s threshold
72
+ const idle = Date.now() - lastFrameAt;
73
+ strict_1.default.ok(idle > IDLE_TIMEOUT_MS, 'Should be over idle threshold');
74
+ });
75
+ (0, node_test_1.it)('does not flag fresh connection as stale', () => {
76
+ const lastFrameAt = Date.now() - 30000; // 30s ago, under 90s threshold
77
+ const idle = Date.now() - lastFrameAt;
78
+ strict_1.default.ok(idle < IDLE_TIMEOUT_MS, 'Should be under idle threshold');
79
+ });
80
+ (0, node_test_1.it)('reset pendingApproval on non-pending close', () => {
81
+ let pendingApproval = true;
82
+ const closeCode = 1000; // normal close, not 4003
83
+ if (pendingApproval && closeCode !== 4003) {
84
+ pendingApproval = false;
85
+ }
86
+ strict_1.default.equal(pendingApproval, false, 'pendingApproval should be reset on normal close');
87
+ });
88
+ (0, node_test_1.it)('keep pendingApproval on 4003 close code', () => {
89
+ let pendingApproval = true;
90
+ const closeCode = 4003; // PENDING_APPROVAL code
91
+ if (pendingApproval && closeCode !== 4003) {
92
+ pendingApproval = false;
93
+ }
94
+ strict_1.default.equal(pendingApproval, true, 'pendingApproval should stay on PENDING_APPROVAL close');
95
+ });
96
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-channel",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "description": "Nexus Hub 2.0 channel plugin for OpenClaw — enables agents to connect to Nexus Hub as a channel, with A2A dispatch, room summary, and Control Plane management.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,9 @@
26
26
  "peerDependencies": {
27
27
  "openclaw": "*"
28
28
  },
29
- "bundledDependencies": ["ws"],
29
+ "bundledDependencies": [
30
+ "ws"
31
+ ],
30
32
  "openclaw": {
31
33
  "extensions": [
32
34
  "./dist/index.js"