@openagents-org/agent-launcher 0.2.143 → 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.
- package/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +108 -7
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +10 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +24 -7
- package/src/config.js +8 -2
- package/src/daemon.js +86 -5
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
package/src/adapters/nanoclaw.js
CHANGED
|
@@ -1,22 +1,674 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NanoClaw adapter
|
|
2
|
+
* NanoClaw adapter for OpenAgents workspace.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
|
52
|
+
class NanoClawAdapter extends BaseAdapter {
|
|
13
53
|
constructor(opts) {
|
|
14
|
-
super(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|