@openagents-org/agent-launcher 0.2.142 → 0.2.145

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.
@@ -1,22 +1,674 @@
1
1
  /**
2
- * NanoClaw adapter — lightweight containerized coding agent.
2
+ * NanoClaw adapter for OpenAgents workspace.
3
3
  *
4
- * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: sdk/src/openagents/adapters/nanoclaw.py
4
+ * NanoClaw is a CONTAINERIZED agent runtime (each Agent Group runs in its own
5
+ * Docker container; Bun + Claude Agent SDK inside). It is NOT an stdin/stdout
6
+ * CLI and NOT a direct LLM API. This adapter is the OpenAgents-side ORCHESTRATOR
7
+ * that bridges a workspace to a NanoClaw Agent Group via a thin NATIVE NanoClaw
8
+ * `openagents` *channel* (the official extension point) over a local IPC socket
9
+ * (`<home>/data/openagents.sock`).
10
+ *
11
+ * workspace message ──poll──▶ this adapter ──IPC inbound──▶ openagents channel
12
+ * ──native router──▶ session ──▶ container(Agent Group) ──▶ outbound.db
13
+ * ──host delivery──▶ channel.deliver() ──IPC outbound──▶ this adapter
14
+ * ──▶ workspace events (text / status / error)
15
+ *
16
+ * Mapping: OpenAgents Agent ↔ NanoClaw Agent Group (NANOCLAW_AGENT_GROUP)
17
+ * OpenAgents Channel ↔ NanoClaw Session (distinct platform_id ⇒
18
+ * distinct messaging group ⇒ isolated session per channel).
19
+ *
20
+ * Management/health uses NanoClaw's official `ncl` control socket (read-only
21
+ * commands only — create/wiring are approval-gated). We never read or write
22
+ * NanoClaw's databases, and never duplicate the workspace token into NanoClaw.
23
+ *
24
+ * Direct port target: sdk/src/openagents/adapters/nanoclaw.py
6
25
  */
7
26
 
8
27
  'use strict';
9
28
 
10
- const LlmDirectAdapter = require('./llm-direct');
29
+ const BaseAdapter = require('./base');
30
+ const { formatAttachmentsForPrompt } = require('./utils');
31
+ const proto = require('./nanoclaw-protocol');
32
+ const {
33
+ findNanoclawHome,
34
+ nclSocketPath,
35
+ bridgeSocketPath,
36
+ readBridgeSecret,
37
+ checkDocker,
38
+ checkNode,
39
+ checkPackageManager,
40
+ NclControl,
41
+ } = require('./nanoclaw-control');
42
+ const { NanoclawBridge } = require('./nanoclaw-bridge');
43
+ const { DeliveryStore, defaultStorePath } = require('./nanoclaw-delivery-store');
44
+
45
+ const DEFAULT_REPLY_TIMEOUT_MS = 180000; // cold container start can take a while
46
+ const DEFAULT_SILENCE_MS = 6000; // end a turn this long after the last reply chunk
47
+ const DEFAULT_CONNECT_WAIT_MS = 8000; // wait for the channel to be 'ready' before erroring
48
+ const PREFLIGHT_TTL_MS = 30000; // cache agent-group/wiring checks
49
+ const STATUS_POLL_MS = 10000; // coarse container_status watchdog
50
+ const DETACHED_THREADS_CAP = 500; // bound on suppressed (detached) thread ids
11
51
 
12
- class NanoClawAdapter extends LlmDirectAdapter {
52
+ class NanoClawAdapter extends BaseAdapter {
13
53
  constructor(opts) {
14
- super({
15
- ...opts,
16
- adapterLabel: 'NanoClaw',
17
- modelEnvVar: 'NANOCLAW_MODEL',
54
+ super(opts);
55
+ const env = this.agentEnv || process.env;
56
+
57
+ this._cfgAgentGroup = (env.NANOCLAW_AGENT_GROUP || env.NANOCLAW_GROUP || '').trim();
58
+ this._replyTimeoutMs = parseInt(env.NANOCLAW_REPLY_TIMEOUT_MS || '', 10) || DEFAULT_REPLY_TIMEOUT_MS;
59
+ this._silenceMs = parseInt(env.NANOCLAW_REPLY_SILENCE_MS || '', 10) || DEFAULT_SILENCE_MS;
60
+ this._connectWaitMs = parseInt(env.NANOCLAW_CONNECT_TIMEOUT_MS || '', 10) || DEFAULT_CONNECT_WAIT_MS;
61
+
62
+ // Secrets to scrub from any log line.
63
+ this._secrets = [this.token, env.NANOCLAW_TOKEN, env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN].filter(Boolean);
64
+
65
+ // Resolve the NanoClaw checkout (sync best-effort; re-checked on start).
66
+ const homeInfo = findNanoclawHome(env);
67
+ this._home = homeInfo ? homeInfo.home : null;
68
+ this._homeSource = homeInfo ? homeInfo.source : null;
69
+
70
+ this._control = null;
71
+ this._bridge = null;
72
+
73
+ // Turn collectors keyed by platformId.
74
+ this._pending = new Map();
75
+ // Persistent dedup of PROCESSED outbound (delivered or intentionally
76
+ // dropped) — survives bridge restart; drives ACK + no-re-display on replay.
77
+ // NANOCLAW_STATE_DIR overrides the location (used by tests).
78
+ this._delivered = new DeliveryStore(
79
+ defaultStorePath(this.workspaceId, this.agentName, (env.NANOCLAW_STATE_DIR || '').trim() || undefined),
80
+ ).load();
81
+ // Loop guard: ids we injected (so an echo can never be re-forwarded).
82
+ this._injectedIds = new Set();
83
+ // Reliable detach via thread rotation. NanoClaw exposes NO outbound-drained /
84
+ // queue-empty / delivery-completed signal, and container_status 'stopped' does
85
+ // NOT prove the host delivery sweep has flushed the session's last outbound
86
+ // (the sweep delivers any 'active' session's undelivered rows regardless of
87
+ // container state). So we do NOT wait on / reuse the old session. Instead each
88
+ // Stop bumps the channel's delivery epoch → a new threadId → a fresh NanoClaw
89
+ // session (resolveSession per-thread under the SAME wiring; no approval, no DB
90
+ // access). The OLD threadId is recorded: NanoClaw stamps every outbound with
91
+ // the AUTHORITATIVE thread_id of its triggering inbound, so the old session's
92
+ // replies are suppressed reliably — even if they arrive long after the new
93
+ // message. Detach state is per-process and persists across bridge reconnects.
94
+ this._activeTurn = new Map(); // channel → in-flight turnId (turn collector)
95
+ this._channelEpoch = new Map(); // channel → epoch int (delivery thread generation)
96
+ this._detachedThreads = new Set(); // threadIds whose outbound must be dropped
97
+
98
+ // Resolved Agent Group, cached preflight, container status.
99
+ this._agentGroup = null; // {id, name}
100
+ this._agentGroupErr = null;
101
+ this._preflight = { at: 0, ok: false, code: null };
102
+ this._containerStatus = new Map(); // sessionId/platformId → status
103
+ this._statusPoller = null;
104
+ this._stopping = false;
105
+ this._authError = null; // set when the channel handshake is rejected
106
+ }
107
+
108
+ _redact(s) {
109
+ return proto.redactSecrets(s, this._secrets);
110
+ }
111
+
112
+ // ------------------------------------------------------------------
113
+ // Lifecycle
114
+ // ------------------------------------------------------------------
115
+
116
+ async run() {
117
+ await this._startBridge();
118
+ return super.run();
119
+ }
120
+
121
+ async _startBridge() {
122
+ const env = this.agentEnv || process.env;
123
+ // Re-resolve home at start (the checkout may appear after construction).
124
+ if (!this._home) {
125
+ const hi = findNanoclawHome(env);
126
+ if (hi) {
127
+ this._home = hi.home;
128
+ this._homeSource = hi.source;
129
+ }
130
+ }
131
+
132
+ if (!this._home) {
133
+ this._log('NanoClaw checkout not found — set NANOCLAW_HOME or put `ncl` on PATH. Messages will report this until configured.');
134
+ return;
135
+ }
136
+ this._log(`NanoClaw home: ${this._home} (${this._homeSource})`);
137
+
138
+ this._control = new NclControl(nclSocketPath(this._home));
139
+ this._bridge = new NanoclawBridge(bridgeSocketPath(this._home), {
140
+ workspace: this.workspaceId,
141
+ agent: this.agentName,
142
+ // Read the handshake secret fresh per connect (the channel rotates it on
143
+ // each host restart; this also re-authenticates on every reconnect).
144
+ secretProvider: () => {
145
+ const s = this._home ? readBridgeSecret(this._home) : null;
146
+ if (s && !this._secrets.includes(s)) this._secrets.push(s); // keep it out of logs
147
+ return s;
148
+ },
149
+ log: (m) => this._log(this._redact(m)),
150
+ });
151
+
152
+ this._bridge.on('outbound', (f) => this._onOutbound(f));
153
+ this._bridge.on('status', (f) => this._onStatusFrame(f));
154
+ this._bridge.on('error-frame', (f) => this._onErrorFrame(f));
155
+ this._bridge.on('dropped', (f) => this._onDropped(f));
156
+ this._bridge.on('auth-failed', (code) => {
157
+ this._authError =
158
+ code === 'incompatible_version'
159
+ ? proto.ERR.INCOMPATIBLE_VERSION
160
+ : code === 'single_connection'
161
+ ? proto.ERR.SINGLE_CONNECTION
162
+ : proto.ERR.AUTH_FAILED;
163
+ this._log(`NanoClaw channel handshake rejected (${code})`);
164
+ });
165
+ this._bridge.on('ready', () => {
166
+ this._authError = null;
167
+ this._log('NanoClaw openagents channel ready');
168
+ });
169
+ this._bridge.on('reconnecting', (n, d) => this._log(`Reconnecting to NanoClaw channel (attempt ${n}, ${d}ms)`));
170
+ this._bridge.on('disconnect', () => this._onBridgeDisconnect());
171
+ this._bridge.connect();
172
+
173
+ // Coarse watchdog: detect containers that crash while we're awaiting a reply.
174
+ this._statusPoller = setInterval(() => {
175
+ this._pollContainerStatus().catch(() => {});
176
+ }, STATUS_POLL_MS);
177
+ if (this._statusPoller.unref) this._statusPoller.unref();
178
+ }
179
+
180
+ stop() {
181
+ this._stopping = true;
182
+ if (this._statusPoller) {
183
+ clearInterval(this._statusPoller);
184
+ this._statusPoller = null;
185
+ }
186
+ // Settle any in-flight turns so per-channel workers don't hang.
187
+ for (const st of this._pending.values()) {
188
+ try {
189
+ st.settle({ stopped: true });
190
+ } catch {
191
+ /* best-effort */
192
+ }
193
+ }
194
+ this._pending.clear();
195
+ this._activeTurn.clear();
196
+ this._detachedThreads.clear();
197
+ this._channelEpoch.clear();
198
+ if (this._bridge) {
199
+ try {
200
+ this._bridge.close();
201
+ } catch {
202
+ /* best-effort */
203
+ }
204
+ }
205
+ // Note: we deliberately do NOT stop the NanoClaw host or any container —
206
+ // those are shared services other channels/agent groups may rely on.
207
+ super.stop();
208
+ }
209
+
210
+ // ------------------------------------------------------------------
211
+ // Incoming workspace message
212
+ // ------------------------------------------------------------------
213
+
214
+ async _handleMessage(msg) {
215
+ // Loop / echo guard — never forward our own (or another agent's) output.
216
+ if (!proto.shouldForwardInbound(msg, this.agentName)) return;
217
+
218
+ let content = (msg.content || '').trim();
219
+ const attText = formatAttachmentsForPrompt(msg.attachments || []);
220
+ if (attText) content = content ? content + attText : attText.trim();
221
+ if (!content) return;
222
+
223
+ const channel = this._channelFor(msg);
224
+ const platformId = proto.platformIdFor(this.workspaceId, channel);
225
+ const msgId = proto.makeMessageId(msg, this.workspaceId);
226
+ const turnId = msgId; // one turn per source message (stable across redelivery)
227
+ // Current delivery epoch → threadId → NanoClaw session for this channel. A
228
+ // prior Stop bumped the epoch, so a new message lands on a fresh session.
229
+ const threadId = this._threadIdFor(channel);
230
+ // Mark the turn in-flight synchronously (before any await) so a Stop during
231
+ // preconditions still detaches the correct channel/thread.
232
+ this._activeTurn.set(channel, turnId);
233
+
234
+ // Duplicate guard: same source message already in flight / just handled.
235
+ if (this._injectedIds.has(msgId) && this._pending.has(platformId)) {
236
+ this._log(`Duplicate message ignored (${msgId})`);
237
+ return;
238
+ }
239
+
240
+ // Environment / connectivity preconditions → actionable user error.
241
+ const pre = await this._preconditions(channel);
242
+ if (!pre.ok) {
243
+ this._activeTurn.delete(channel);
244
+ await this.sendError(channel, pre.userMessage);
245
+ return;
246
+ }
247
+
248
+ await this._autoTitleChannel(channel, content);
249
+ await this.sendStatus(channel, 'thinking...');
250
+
251
+ // Inject and await the turn.
252
+ this._injectedIds.add(msgId);
253
+ let injected = false;
254
+ try {
255
+ injected = await this._inject(platformId, channel, msgId, turnId, threadId, content);
256
+ } catch (e) {
257
+ const c = proto.classifyError(e, { secrets: this._secrets });
258
+ this._log(`Inject failed: ${c.detail}`);
259
+ await this.sendError(channel, c.userMessage);
260
+ return;
261
+ }
262
+ if (!injected) {
263
+ await this.sendError(channel, proto.classifyError({ code: proto.ERR.SEND_FAILED }).userMessage);
264
+ return;
265
+ }
266
+
267
+ const outcome = await this._awaitTurn(platformId, channel, msgId);
268
+ if (this._activeTurn.get(channel) === turnId) this._activeTurn.delete(channel);
269
+ if (outcome.timedOut && !outcome.firstReplySeen && !outcome.stopped && !outcome.detached) {
270
+ await this.sendError(channel, proto.classifyError({ code: proto.ERR.TIMEOUT }).userMessage);
271
+ }
272
+ }
273
+
274
+ // ------------------------------------------------------------------
275
+ // Stop = detach (NOT cancel). NanoClaw has no native per-message cancel
276
+ // from a channel surface, so a "stop" stops OpenAgents from WAITING on and
277
+ // DELIVERING the current turn — it does NOT stop the container task, and it
278
+ // never kills the Agent Group, the NanoClaw host, or any shared container.
279
+ // ------------------------------------------------------------------
280
+
281
+ async _onControlAction(action, payload) {
282
+ if (action === 'stop') {
283
+ const ch = payload && (payload.channel || payload.sessionId || payload.session);
284
+ if (ch) this._detachChannel(String(ch));
285
+ else for (const c of this._activeChannels()) this._detachChannel(c);
286
+ return;
287
+ }
288
+ await super._onControlAction(action, payload);
289
+ }
290
+
291
+ _activeChannels() {
292
+ return [...this._activeTurn.keys()];
293
+ }
294
+
295
+ /** Current threadId for a channel's delivery epoch (→ NanoClaw session). */
296
+ _threadIdFor(channel) {
297
+ return `oa-${this._channelEpoch.get(channel) || 0}`;
298
+ }
299
+
300
+ /** Key for the detached-thread set: platformId-scoped (NUL separator) so an
301
+ * `oa-<epoch>` thread on one channel never matches another channel's. */
302
+ _detachKey(platformId, threadId) {
303
+ return `${platformId}${threadId}`;
304
+ }
305
+
306
+ /**
307
+ * Stop = detach. Record the channel's CURRENT threadId so the old (still
308
+ * running) session's replies are dropped, and bump the epoch so the NEXT
309
+ * message starts a FRESH NanoClaw session/thread — officially-supported
310
+ * per-thread routing under the same wiring (no approval, no DB access).
311
+ *
312
+ * NanoClaw exposes no outbound-drained signal, so we never wait on / reuse the
313
+ * old session. The old session's replies carry the AUTHORITATIVE old thread_id
314
+ * (NanoClaw stamps outbound with the triggering inbound's thread_id), so they
315
+ * are suppressed reliably — even if they arrive after the new message. This
316
+ * never cancels the container task or kills the host / containers / other
317
+ * sessions.
318
+ */
319
+ _detachChannel(channel) {
320
+ const platformId = proto.platformIdFor(this.workspaceId, channel);
321
+ this._detachedThreads.add(this._detachKey(platformId, this._threadIdFor(channel)));
322
+ while (this._detachedThreads.size > DETACHED_THREADS_CAP) {
323
+ const oldest = this._detachedThreads.values().next().value;
324
+ if (oldest === undefined) break;
325
+ this._detachedThreads.delete(oldest);
326
+ }
327
+ this._channelEpoch.set(channel, (this._channelEpoch.get(channel) || 0) + 1);
328
+ this._activeTurn.delete(channel);
329
+ const state = this._pending.get(platformId);
330
+ if (state && state.settle) state.settle({ detached: true });
331
+ if (this._bridge) this._bridge.sendCancel(platformId, null); // best-effort notify
332
+ this.sendStatus(
333
+ channel,
334
+ 'Stopped — replies from the previous task are dropped; a new message starts a fresh NanoClaw session. The previous task may keep running in the background.',
335
+ { nanoclaw_state: 'detached' },
336
+ ).catch(() => {});
337
+ }
338
+
339
+ /** A queued NanoClaw reply was dropped (overflow / expiry / corrupt) — surface it. */
340
+ _onDropped(frame) {
341
+ // Corrupt records have no recoverable platformId; fall back to the default channel.
342
+ const channel = proto.channelFromPlatformId(frame.platformId, this.workspaceId) || this.channelName || 'general';
343
+ const code =
344
+ frame.reason === 'expired'
345
+ ? proto.ERR.DELIVERY_EXPIRED
346
+ : frame.reason === 'corrupt'
347
+ ? proto.ERR.DELIVERY_CORRUPT
348
+ : proto.ERR.DELIVERY_OVERFLOW;
349
+ this._log(`NanoClaw outbox dropped a reply (${frame.reason})`);
350
+ this.sendStatus(channel, proto.classifyError({ code }).userMessage, { nanoclaw_state: code }).catch(() => {});
351
+ }
352
+
353
+ _channelFor(msg) {
354
+ let channel = this.channelName || 'general';
355
+ if (msg.sessionId && !msg.sessionId.startsWith('openagents:') && !msg.sessionId.startsWith('agent:')) {
356
+ channel = msg.sessionId;
357
+ }
358
+ return channel;
359
+ }
360
+
361
+ /** Send inbound, retrying once across a short reconnect window. */
362
+ async _inject(platformId, channel, msgId, turnId, threadId, text) {
363
+ const fields = {
364
+ platformId,
365
+ threadId,
366
+ msgId,
367
+ turnId,
368
+ text,
369
+ sender: 'user',
370
+ senderId: `oa:${this.workspaceId}`,
371
+ };
372
+ if (!this._bridge) throw Object.assign(new Error('bridge unavailable'), { code: proto.ERR.HOST_NOT_RUNNING });
373
+
374
+ if (this._bridge.isReady()) {
375
+ return this._bridge.sendInbound(fields);
376
+ }
377
+ // Wait briefly for the channel to become ready (host restart / late load).
378
+ const ready = await this._waitReady(this._connectWaitMs);
379
+ if (!ready) throw Object.assign(new Error('openagents channel not ready'), { code: proto.ERR.CHANNEL_NOT_LOADED });
380
+ return this._bridge.sendInbound(fields);
381
+ }
382
+
383
+ _waitReady(timeoutMs) {
384
+ if (this._bridge && this._bridge.isReady()) return Promise.resolve(true);
385
+ return new Promise((resolve) => {
386
+ let done = false;
387
+ const finish = (v) => {
388
+ if (done) return;
389
+ done = true;
390
+ clearTimeout(t);
391
+ if (this._bridge) this._bridge.removeListener('ready', onReady);
392
+ resolve(v);
393
+ };
394
+ const onReady = () => finish(true);
395
+ const t = setTimeout(() => finish(false), timeoutMs);
396
+ if (t.unref) t.unref();
397
+ if (this._bridge) this._bridge.once('ready', onReady);
398
+ else finish(false);
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Block until the turn settles: hard timeout, or a silence window after the
404
+ * last reply chunk, or an explicit idle status, or adapter stop. Replies are
405
+ * delivered by the global _onOutbound router; this just observes for UX.
406
+ */
407
+ _awaitTurn(platformId, channel) {
408
+ return new Promise((resolve) => {
409
+ const state = {
410
+ channel,
411
+ firstReplySeen: false,
412
+ silenceTimer: null,
413
+ hardTimer: null,
414
+ settle: null,
415
+ };
416
+ const settle = (extra = {}) => {
417
+ if (state._settled) return;
418
+ state._settled = true;
419
+ if (state.silenceTimer) clearTimeout(state.silenceTimer);
420
+ if (state.hardTimer) clearTimeout(state.hardTimer);
421
+ this._pending.delete(platformId);
422
+ resolve({
423
+ firstReplySeen: state.firstReplySeen,
424
+ timedOut: !!extra.timedOut,
425
+ stopped: !!extra.stopped,
426
+ detached: !!extra.detached,
427
+ });
428
+ };
429
+ state.settle = settle;
430
+ state.armSilence = () => {
431
+ if (state.silenceTimer) clearTimeout(state.silenceTimer);
432
+ state.silenceTimer = setTimeout(() => settle({ timedOut: false }), this._silenceMs);
433
+ if (state.silenceTimer.unref) state.silenceTimer.unref();
434
+ };
435
+ state.hardTimer = setTimeout(() => settle({ timedOut: true }), this._replyTimeoutMs);
436
+ if (state.hardTimer.unref) state.hardTimer.unref();
437
+ this._pending.set(platformId, state);
18
438
  });
19
439
  }
440
+
441
+ // ------------------------------------------------------------------
442
+ // Outbound from NanoClaw (replies / status / errors)
443
+ // ------------------------------------------------------------------
444
+
445
+ async _onOutbound(frame) {
446
+ const platformId = frame.platformId;
447
+ const channel = proto.channelFromPlatformId(platformId, this.workspaceId);
448
+ // Not our workspace — leave it for the owning bridge; do NOT ACK (acking
449
+ // someone else's message would drop it from the channel's replay buffer).
450
+ if (!channel) return;
451
+
452
+ const outId = typeof frame.outId === 'string' ? frame.outId : '';
453
+ const text = typeof frame.text === 'string' ? frame.text : '';
454
+
455
+ // Already processed (a replay after reconnect / a re-delivery after a bridge
456
+ // restart): re-ACK so the channel stops holding it, but never re-display.
457
+ if (outId && this._delivered.has(outId)) {
458
+ this._ackOutbound(outId, platformId);
459
+ return;
460
+ }
461
+
462
+ // Observe for the turn collector regardless of suppression.
463
+ const state = this._pending.get(platformId);
464
+ if (state) {
465
+ state.firstReplySeen = true;
466
+ if (state.armSilence) state.armSilence();
467
+ }
468
+
469
+ // Suppress replies of a detached turn (or anything draining on a still-blocked
470
+ // channel — turnId tagging is best-effort, so while blocked we suppress all).
471
+ // We still ACK + mark processed so the channel stops holding/replaying it,
472
+ // and we keep the channel blocked as long as its old turn keeps producing.
473
+ // Suppress replies of a DETACHED session. NanoClaw stamps every outbound with
474
+ // the AUTHORITATIVE thread_id of its triggering inbound, so a detached
475
+ // (platformId, threadId) reliably identifies the old session's replies — even
476
+ // arriving long after a new message on a new thread. Keyed by platformId too
477
+ // so an `oa-<epoch>` thread on one channel never matches another's. ACK + mark
478
+ // processed so the channel stops holding/replaying, but never write it out.
479
+ const fThread = typeof frame.threadId === 'string' ? frame.threadId : '';
480
+ if (fThread && this._detachedThreads.has(this._detachKey(platformId, fThread))) {
481
+ if (outId) {
482
+ this._delivered.add(outId);
483
+ this._ackOutbound(outId, platformId);
484
+ }
485
+ return;
486
+ }
487
+
488
+ if (!text) {
489
+ // Non-text outbound we don't render — still processed; ACK so it isn't
490
+ // replayed forever.
491
+ if (outId) {
492
+ this._delivered.add(outId);
493
+ this._ackOutbound(outId, platformId);
494
+ }
495
+ return;
496
+ }
497
+
498
+ try {
499
+ await this.sendResponse(channel, text); // reliable hand-off to the Workspace
500
+ } catch (e) {
501
+ this._log(`Failed to deliver reply: ${this._redact(e.message)}`);
502
+ return; // do NOT ack/persist → the channel replays it on the next cycle
503
+ }
504
+ // Persist BEFORE ACK so a crash after delivery still dedups on restart.
505
+ if (outId) {
506
+ this._delivered.add(outId);
507
+ this._ackOutbound(outId, platformId);
508
+ }
509
+ }
510
+
511
+ // ACK confirmation point: an outbound is only ACKed AFTER `sendResponse`
512
+ // (which awaits `client.sendMessage` — an HTTP POST to the Workspace REST API)
513
+ // resolves successfully, i.e. the reply is persisted in the Workspace backend,
514
+ // not merely emitted. A failed/rejected POST does NOT ACK, so the channel
515
+ // replays the reply on the next cycle.
516
+ _ackOutbound(outId, platformId) {
517
+ if (this._bridge) this._bridge.sendAck(outId, platformId);
518
+ }
519
+
520
+ async _onStatusFrame(frame) {
521
+ const channel = proto.channelFromPlatformId(frame.platformId, this.workspaceId);
522
+ if (!channel) return;
523
+ if (frame.state === 'working') {
524
+ this.sendStatus(channel, 'working...').catch(() => {});
525
+ } else if (frame.state === 'idle') {
526
+ const state = this._pending.get(frame.platformId);
527
+ // Idle after a reply → settle promptly instead of waiting out the silence.
528
+ if (state && state.firstReplySeen && state.settle) state.settle({ timedOut: false });
529
+ }
530
+ }
531
+
532
+ async _onErrorFrame(frame) {
533
+ const channel = proto.channelFromPlatformId(frame.platformId, this.workspaceId);
534
+ if (!channel) return;
535
+ const msg = this._redact(frame.message || 'NanoClaw reported an error.');
536
+ this._log(`Channel error: ${msg}`);
537
+ try {
538
+ await this.sendError(channel, msg);
539
+ } catch {
540
+ /* best-effort */
541
+ }
542
+ }
543
+
544
+ _onBridgeDisconnect() {
545
+ this._log('NanoClaw channel disconnected — reconnecting (replies will resume).');
546
+ for (const st of this._pending.values()) {
547
+ this.sendStatus(st.channel, 'reconnecting to NanoClaw…').catch(() => {});
548
+ }
549
+ }
550
+
551
+ // ------------------------------------------------------------------
552
+ // Preconditions & status
553
+ // ------------------------------------------------------------------
554
+
555
+ async _preconditions(channel) {
556
+ if (!this._home) return { ok: false, userMessage: proto.classifyError({ code: proto.ERR.NOT_INSTALLED }).userMessage };
557
+ if (!this._bridge) return { ok: false, userMessage: proto.classifyError({ code: proto.ERR.HOST_NOT_RUNNING }).userMessage };
558
+ if (this._authError) return { ok: false, userMessage: proto.classifyError({ code: this._authError }).userMessage };
559
+
560
+ // Cached agent-group + wiring preflight (also confirms host reachability).
561
+ const now = Date.now();
562
+ if (now - this._preflight.at < PREFLIGHT_TTL_MS && this._preflight.code) {
563
+ if (!this._preflight.ok) return { ok: false, userMessage: proto.classifyError({ code: this._preflight.code }).userMessage };
564
+ } else {
565
+ const pf = await this._runPreflight();
566
+ this._preflight = { at: now, ok: pf.ok, code: pf.code };
567
+ if (!pf.ok) return { ok: false, userMessage: proto.classifyError({ code: pf.code }).userMessage };
568
+ }
569
+ return { ok: true };
570
+ }
571
+
572
+ /** Verify host up + agent group exists + wired. Read-only ncl commands. */
573
+ async _runPreflight() {
574
+ try {
575
+ const groups = await this._control.listGroups();
576
+ this._lastGroups = groups;
577
+ const ag = this._matchAgentGroup(groups);
578
+ if (!ag) return { ok: false, code: proto.ERR.AGENT_GROUP_MISSING };
579
+ this._agentGroup = ag;
580
+
581
+ const wirings = await this._control.listWirings({ agent_group_id: ag.id }).catch(() => []);
582
+ const hasOpenagentsWiring = Array.isArray(wirings) && wirings.length > 0
583
+ ? await this._hasOpenagentsWiring(wirings)
584
+ : false;
585
+ if (!hasOpenagentsWiring) return { ok: false, code: proto.ERR.WIRING_MISSING };
586
+
587
+ return { ok: true, code: 'ok' };
588
+ } catch (e) {
589
+ const c = proto.classifyError(e, { secrets: this._secrets });
590
+ // host unreachable / socket missing
591
+ if (c.code === proto.ERR.TIMEOUT || (e && (e.nclCode === 'host-not-running' || e.nclCode === 'transport-error'))) {
592
+ return { ok: false, code: proto.ERR.HOST_NOT_RUNNING };
593
+ }
594
+ return { ok: false, code: c.code };
595
+ }
596
+ }
597
+
598
+ _matchAgentGroup(groups) {
599
+ if (!Array.isArray(groups) || groups.length === 0) return null;
600
+ const want = this._cfgAgentGroup;
601
+ if (!want) {
602
+ // No explicit selection: only safe when there is exactly one group.
603
+ return groups.length === 1 ? { id: groups[0].id, name: groups[0].name } : null;
604
+ }
605
+ const byId = groups.find((g) => g.id === want);
606
+ if (byId) return { id: byId.id, name: byId.name };
607
+ const byName = groups.find((g) => (g.name || '').toLowerCase() === want.toLowerCase());
608
+ return byName ? { id: byName.id, name: byName.name } : null;
609
+ }
610
+
611
+ /** Does the agent group have a wiring whose messaging group is channel_type 'openagents'? */
612
+ async _hasOpenagentsWiring(wirings) {
613
+ for (const w of wirings) {
614
+ const mgId = w.messaging_group_id || w.messagingGroupId;
615
+ if (!mgId) continue;
616
+ try {
617
+ const mg = await this._control.request('messaging-groups-get', { id: mgId });
618
+ if (mg && (mg.channel_type === proto.CHANNEL_TYPE || mg.channelType === proto.CHANNEL_TYPE)) return true;
619
+ } catch {
620
+ /* ignore individual lookup errors */
621
+ }
622
+ }
623
+ return false;
624
+ }
625
+
626
+ async _pollContainerStatus() {
627
+ if (this._stopping || !this._control || !this._agentGroup) return;
628
+ let sessions;
629
+ try {
630
+ sessions = await this._control.listSessions({ agent_group_id: this._agentGroup.id });
631
+ } catch {
632
+ return;
633
+ }
634
+ for (const s of sessions || []) {
635
+ const prev = this._containerStatus.get(s.id);
636
+ this._containerStatus.set(s.id, s.container_status);
637
+ // A container that flips running→stopped while we await a reply ≈ crash.
638
+ if (prev === 'running' && s.container_status === 'stopped') {
639
+ this._log(`Container for session ${String(s.id).slice(0, 8)} stopped unexpectedly.`);
640
+ }
641
+ }
642
+ }
643
+
644
+ // ------------------------------------------------------------------
645
+ // Detection surface (used by health checks / launcher)
646
+ // ------------------------------------------------------------------
647
+
648
+ static async detect(env = process.env, opts = {}) {
649
+ const homeInfo = findNanoclawHome(env);
650
+ const node = checkNode();
651
+ const pm = checkPackageManager();
652
+ const docker = await checkDocker(opts.dockerRunner);
653
+ const out = {
654
+ installed: !!homeInfo,
655
+ home: homeInfo ? homeInfo.home : null,
656
+ node,
657
+ packageManager: pm,
658
+ docker,
659
+ hostRunning: false,
660
+ };
661
+ if (homeInfo) {
662
+ try {
663
+ const ctl = new NclControl(nclSocketPath(homeInfo.home), { timeoutMs: 4000 });
664
+ await ctl.ping();
665
+ out.hostRunning = true;
666
+ } catch {
667
+ out.hostRunning = false;
668
+ }
669
+ }
670
+ return out;
671
+ }
20
672
  }
21
673
 
22
674
  module.exports = NanoClawAdapter;