lazyclaw 3.99.28 → 4.2.0
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/README.md +128 -2
- package/agents.mjs +179 -0
- package/channels/base.mjs +120 -0
- package/channels/http.mjs +54 -0
- package/channels/slack.mjs +386 -0
- package/channels/stub.mjs +52 -0
- package/cli.mjs +1628 -75
- package/daemon.mjs +171 -0
- package/docs/multi-agent.md +256 -0
- package/goals.mjs +128 -0
- package/loop-engine.mjs +182 -0
- package/loops.mjs +135 -0
- package/mas/agent_memory.mjs +188 -0
- package/mas/agent_turn.mjs +141 -0
- package/mas/audit.mjs +62 -0
- package/mas/mention_router.mjs +360 -0
- package/mas/tool_runner.mjs +87 -0
- package/mas/tools/bash.mjs +78 -0
- package/mas/tools/grep.mjs +91 -0
- package/mas/tools/read.mjs +45 -0
- package/mas/tools/write.mjs +42 -0
- package/memory.mjs +193 -0
- package/package.json +26 -6
- package/providers/registry.mjs +8 -1
- package/providers/tool_use/anthropic.mjs +151 -0
- package/providers/tool_use/gemini.mjs +189 -0
- package/providers/tool_use/openai.mjs +140 -0
- package/scripts/loop-worker.mjs +160 -0
- package/sessions.mjs +5 -0
- package/tasks.mjs +220 -0
- package/teams.mjs +199 -0
- package/web/dashboard.html +166 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Slack channel adapter.
|
|
2
|
+
//
|
|
3
|
+
// Reads three secrets from the environment ONLY (never from goal files,
|
|
4
|
+
// never logged):
|
|
5
|
+
// SLACK_BOT_TOKEN xoxb-… — used to call chat.postMessage etc.
|
|
6
|
+
// SLACK_APP_TOKEN xapp-… — required for Socket Mode (inbound)
|
|
7
|
+
// SLACK_SIGNING_SECRET … — used to verify webhook payloads when
|
|
8
|
+
// we add Events API mode (not yet)
|
|
9
|
+
//
|
|
10
|
+
// Outbound (`send(threadId, text)`) only needs the bot token. Inbound
|
|
11
|
+
// arrives via Socket Mode — `_connectSocketMode()` opens a WebSocket to
|
|
12
|
+
// Slack's gateway (negotiated by `apps.connections.open`) and dispatches
|
|
13
|
+
// every `events_api` envelope through `_simulateInbound(text, threadId)`.
|
|
14
|
+
// `start()` only validates env so unit tests can drive `_simulateInbound`
|
|
15
|
+
// directly without bringing up a WebSocket; the CLI's `slack listen`
|
|
16
|
+
// subcommand calls `_connectSocketMode()` explicitly after `start()`.
|
|
17
|
+
//
|
|
18
|
+
// SLACK_API_BASE (test-only) overrides the Slack Web API base URL so the
|
|
19
|
+
// Phase 8 spec can point the adapter at a local mock HTTP server.
|
|
20
|
+
|
|
21
|
+
import { Channel, ChannelGated } from './base.mjs';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_API_BASE = 'https://slack.com/api';
|
|
24
|
+
|
|
25
|
+
export class SlackError extends Error {
|
|
26
|
+
constructor(message, code, missing) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'SlackError';
|
|
29
|
+
this.code = code || 'SLACK_ERR';
|
|
30
|
+
if (Array.isArray(missing)) this.missing = missing;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readSlackEnv(env = process.env) {
|
|
35
|
+
const out = {
|
|
36
|
+
botToken: env.SLACK_BOT_TOKEN || null,
|
|
37
|
+
appToken: env.SLACK_APP_TOKEN || null,
|
|
38
|
+
signingSecret: env.SLACK_SIGNING_SECRET || null,
|
|
39
|
+
apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
|
|
40
|
+
};
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateEnv(env, { requireInbound = false } = {}) {
|
|
45
|
+
const missing = [];
|
|
46
|
+
if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
|
|
47
|
+
else if (!env.botToken.startsWith('xoxb-')) {
|
|
48
|
+
throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
|
|
49
|
+
}
|
|
50
|
+
if (requireInbound) {
|
|
51
|
+
if (!env.appToken) missing.push('SLACK_APP_TOKEN');
|
|
52
|
+
else if (!env.appToken.startsWith('xapp-')) {
|
|
53
|
+
throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
|
|
54
|
+
}
|
|
55
|
+
if (!env.signingSecret) missing.push('SLACK_SIGNING_SECRET');
|
|
56
|
+
}
|
|
57
|
+
if (missing.length) {
|
|
58
|
+
throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class SlackChannel extends Channel {
|
|
63
|
+
constructor(opts = {}) {
|
|
64
|
+
super('slack');
|
|
65
|
+
this._env = { ...readSlackEnv(), ...opts };
|
|
66
|
+
this._requireInbound = opts.requireInbound !== false; // default true
|
|
67
|
+
this._socketHandle = null; // populated when Socket Mode connects
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async start(handler, opts = {}) {
|
|
71
|
+
// Validate up-front so a missing-token daemon fails loudly at boot
|
|
72
|
+
// (the Phase 8 spec test asserts this).
|
|
73
|
+
validateEnv(this._env, { requireInbound: this._requireInbound });
|
|
74
|
+
await super.start(handler, opts);
|
|
75
|
+
// Socket Mode connect is intentionally deferred — we keep the
|
|
76
|
+
// adapter pure for the test surface; the production wiring imports
|
|
77
|
+
// @slack/socket-mode or implements the WS handshake directly and
|
|
78
|
+
// funnels every inbound event through _simulateInbound.
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Called by Socket Mode wiring (or tests) for every inbound message
|
|
83
|
+
// routed to this app. The handler returns the bot's reply; the
|
|
84
|
+
// adapter posts it back to Slack in the same thread.
|
|
85
|
+
async _simulateInbound(text, threadId) {
|
|
86
|
+
let reply;
|
|
87
|
+
try {
|
|
88
|
+
reply = await this._processInbound({ threadId, text, gateInput: {} });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
|
|
91
|
+
await this.send(threadId, `(gated: ${err.message})`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await this.send(threadId, `(error: ${err?.message || err})`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await this.send(threadId, reply);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Translate a target spec like `slack:#deploys` or `slack:U012345` into
|
|
101
|
+
// a Slack `channel` string. Threads are addressed by a `threadId` of
|
|
102
|
+
// shape `<channel>:<thread_ts>` or plain channel/user id.
|
|
103
|
+
//
|
|
104
|
+
// opts (Phase 16):
|
|
105
|
+
// username: string — overrides the bot's display name for this
|
|
106
|
+
// single message (requires chat:write.customize
|
|
107
|
+
// scope on the bot token). Silently no-op when
|
|
108
|
+
// the scope is missing.
|
|
109
|
+
// icon_emoji: string — e.g. ":rocket:" — same scope.
|
|
110
|
+
async send(threadId, text, opts = {}) {
|
|
111
|
+
if (!this._env.botToken) throw new SlackError('cannot send without SLACK_BOT_TOKEN', 'SLACK_NO_TOKEN');
|
|
112
|
+
let channel = threadId, thread_ts;
|
|
113
|
+
if (typeof threadId === 'string' && threadId.includes(':')) {
|
|
114
|
+
const ix = threadId.indexOf(':');
|
|
115
|
+
// Allow the test-style "slack:#chan" prefix to flow through.
|
|
116
|
+
if (threadId.slice(0, ix) === 'slack') {
|
|
117
|
+
channel = threadId.slice(ix + 1);
|
|
118
|
+
} else {
|
|
119
|
+
channel = threadId.slice(0, ix);
|
|
120
|
+
thread_ts = threadId.slice(ix + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const url = `${this._env.apiBase.replace(/\/$/, '')}/chat.postMessage`;
|
|
124
|
+
const body = {
|
|
125
|
+
channel,
|
|
126
|
+
text: String(text),
|
|
127
|
+
...(thread_ts ? { thread_ts } : {}),
|
|
128
|
+
...(opts && opts.username ? { username: String(opts.username) } : {}),
|
|
129
|
+
...(opts && opts.icon_emoji ? { icon_emoji: String(opts.icon_emoji) } : {}),
|
|
130
|
+
};
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
'Authorization': `Bearer ${this._env.botToken}`,
|
|
135
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new SlackError(`slack send failed: HTTP ${res.status}`, 'SLACK_HTTP_FAIL');
|
|
141
|
+
}
|
|
142
|
+
const json = await res.json().catch(() => ({}));
|
|
143
|
+
if (!json.ok) {
|
|
144
|
+
throw new SlackError(`slack send failed: ${json.error || 'unknown'}`, 'SLACK_API_FAIL');
|
|
145
|
+
}
|
|
146
|
+
return json;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Open a Socket Mode WebSocket and route every inbound event through
|
|
150
|
+
// `_simulateInbound`. Returns when the listener is connected; the
|
|
151
|
+
// returned object exposes `.close()` for graceful shutdown.
|
|
152
|
+
//
|
|
153
|
+
// opts.logger?: (line: string) => void — diagnostic sink (stderr in
|
|
154
|
+
// the CLI, no-op in tests).
|
|
155
|
+
// opts.maxReconnects?: number — cap reconnect attempts (default ∞).
|
|
156
|
+
async _connectSocketMode({ logger = () => {}, maxReconnects = Infinity } = {}) {
|
|
157
|
+
validateEnv(this._env, { requireInbound: true });
|
|
158
|
+
if (typeof globalThis.WebSocket !== 'function') {
|
|
159
|
+
throw new SlackError(
|
|
160
|
+
'global WebSocket is not available — Node 22+ required for Socket Mode',
|
|
161
|
+
'SLACK_NO_WS'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const apiBase = this._env.apiBase.replace(/\/$/, '');
|
|
165
|
+
const appToken = this._env.appToken;
|
|
166
|
+
const seenEnvelopes = new Set();
|
|
167
|
+
let closed = false;
|
|
168
|
+
let ws = null;
|
|
169
|
+
let attempts = 0;
|
|
170
|
+
|
|
171
|
+
const openConnection = async () => {
|
|
172
|
+
const url = `${apiBase}/apps.connections.open`;
|
|
173
|
+
const res = await fetch(url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
'Authorization': `Bearer ${appToken}`,
|
|
177
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
throw new SlackError(`apps.connections.open HTTP ${res.status}`, 'SLACK_OPEN_HTTP');
|
|
182
|
+
}
|
|
183
|
+
const json = await res.json().catch(() => ({}));
|
|
184
|
+
if (!json.ok || !json.url) {
|
|
185
|
+
throw new SlackError(`apps.connections.open failed: ${json.error || 'no url'}`, 'SLACK_OPEN_FAIL');
|
|
186
|
+
}
|
|
187
|
+
return json.url;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// (channel, ts) dedupe — a single user message can fire both
|
|
191
|
+
// `message` and `app_mention` events in the same channel. Both
|
|
192
|
+
// arrive as separate Socket Mode envelopes (different envelope_id),
|
|
193
|
+
// so the envelope_id-level dedupe upstream doesn't catch them. We
|
|
194
|
+
// claim the pair on first dispatch and reject the second.
|
|
195
|
+
const seenMessages = new Map(); // key → expiresAt(ms)
|
|
196
|
+
const MSG_TTL_MS = 60_000;
|
|
197
|
+
const claimMessage = (channel, ts) => {
|
|
198
|
+
const key = `${channel}:${ts}`;
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
// Sweep expired entries opportunistically so the map doesn't grow
|
|
201
|
+
// unbounded over a long-running session.
|
|
202
|
+
if (seenMessages.size > 256) {
|
|
203
|
+
for (const [k, exp] of seenMessages) if (exp < now) seenMessages.delete(k);
|
|
204
|
+
}
|
|
205
|
+
const exp = seenMessages.get(key);
|
|
206
|
+
if (exp && exp >= now) return false;
|
|
207
|
+
seenMessages.set(key, now + MSG_TTL_MS);
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const dispatchEvent = async (event) => {
|
|
212
|
+
if (!event || typeof event !== 'object') return;
|
|
213
|
+
// Skip the bot's own messages so we don't loop on our own replies.
|
|
214
|
+
if (event.bot_id || event.subtype === 'bot_message') return;
|
|
215
|
+
if (event.type !== 'app_mention' && event.type !== 'message') return;
|
|
216
|
+
// For DMs (`im`) channel_type is 'im'; for channel mentions we only
|
|
217
|
+
// get app_mention events. Either way we have channel + ts.
|
|
218
|
+
const text = typeof event.text === 'string' ? event.text : '';
|
|
219
|
+
const channel = event.channel;
|
|
220
|
+
const sourceTs = event.ts; // the message we react to
|
|
221
|
+
const replyTs = event.thread_ts || event.ts; // the thread root for replies
|
|
222
|
+
if (!channel || !sourceTs) return;
|
|
223
|
+
if (!claimMessage(channel, sourceTs)) {
|
|
224
|
+
logger(`[slack] duplicate ${event.type} for ${channel}:${sourceTs} — skipping\n`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const threadId = `${channel}:${replyTs}`;
|
|
228
|
+
logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
|
|
229
|
+
|
|
230
|
+
// Immediate acknowledgement so the user sees the bot picked up the
|
|
231
|
+
// message before the LLM finishes. Prefer a reaction (no message
|
|
232
|
+
// spam); fall back to a transient text reply when the workspace
|
|
233
|
+
// doesn't grant reactions:write.
|
|
234
|
+
const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
|
|
235
|
+
if (!eyesOk) {
|
|
236
|
+
logger(`[slack] reactions:write missing — falling back to text ack\n`);
|
|
237
|
+
try { await this.send(threadId, '_확인해보겠습니다…_'); }
|
|
238
|
+
catch (err) { logger(`[slack] text ack failed: ${err?.message || err}\n`); }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await this._simulateInbound(text, threadId);
|
|
243
|
+
if (eyesOk) {
|
|
244
|
+
// Swap the "working" reaction for a "done" one so the user can
|
|
245
|
+
// tell at a glance which messages have been answered.
|
|
246
|
+
await this._reaction('remove', channel, sourceTs, 'eyes');
|
|
247
|
+
await this._reaction('add', channel, sourceTs, 'white_check_mark');
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
logger(`[slack] handler error: ${err?.message || err}\n`);
|
|
251
|
+
if (eyesOk) {
|
|
252
|
+
await this._reaction('remove', channel, sourceTs, 'eyes');
|
|
253
|
+
await this._reaction('add', channel, sourceTs, 'x');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const connectOnce = () => new Promise((resolve, reject) => {
|
|
259
|
+
let wsUrl;
|
|
260
|
+
openConnection()
|
|
261
|
+
.then((u) => { wsUrl = u; })
|
|
262
|
+
.catch(reject)
|
|
263
|
+
.then(() => {
|
|
264
|
+
if (!wsUrl) return;
|
|
265
|
+
logger(`[slack] socket-mode dialing wss gateway\n`);
|
|
266
|
+
ws = new WebSocket(wsUrl);
|
|
267
|
+
ws.addEventListener('open', () => {
|
|
268
|
+
attempts = 0;
|
|
269
|
+
logger(`[slack] socket-mode connected\n`);
|
|
270
|
+
resolve();
|
|
271
|
+
});
|
|
272
|
+
ws.addEventListener('message', async (ev) => {
|
|
273
|
+
let frame;
|
|
274
|
+
try { frame = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString()); }
|
|
275
|
+
catch { return; }
|
|
276
|
+
if (frame.type === 'hello') {
|
|
277
|
+
logger(`[slack] hello (num_connections=${frame.num_connections || '?'})\n`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (frame.type === 'disconnect') {
|
|
281
|
+
logger(`[slack] disconnect requested (reason=${frame.reason || '?'})\n`);
|
|
282
|
+
try { ws.close(1000); } catch { /* best-effort */ }
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (frame.type === 'events_api') {
|
|
286
|
+
if (frame.envelope_id) {
|
|
287
|
+
if (seenEnvelopes.has(frame.envelope_id)) return;
|
|
288
|
+
seenEnvelopes.add(frame.envelope_id);
|
|
289
|
+
// Bound the dedupe set so it doesn't grow forever.
|
|
290
|
+
if (seenEnvelopes.size > 1024) {
|
|
291
|
+
const trimTo = 512;
|
|
292
|
+
const it = seenEnvelopes.values();
|
|
293
|
+
while (seenEnvelopes.size > trimTo) seenEnvelopes.delete(it.next().value);
|
|
294
|
+
}
|
|
295
|
+
try { ws.send(JSON.stringify({ envelope_id: frame.envelope_id })); }
|
|
296
|
+
catch (err) { logger(`[slack] ack send failed: ${err?.message || err}\n`); }
|
|
297
|
+
}
|
|
298
|
+
const event = frame.payload?.event;
|
|
299
|
+
await dispatchEvent(event);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
ws.addEventListener('close', () => {
|
|
303
|
+
logger(`[slack] socket closed\n`);
|
|
304
|
+
if (closed) return;
|
|
305
|
+
attempts++;
|
|
306
|
+
if (attempts > maxReconnects) {
|
|
307
|
+
logger(`[slack] giving up after ${attempts} reconnect attempts\n`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const backoff = Math.min(30000, 1000 * Math.pow(2, Math.min(attempts, 5)));
|
|
311
|
+
logger(`[slack] reconnecting in ${backoff}ms (attempt ${attempts})\n`);
|
|
312
|
+
setTimeout(() => { if (!closed) connectOnce().catch((e) => logger(`[slack] reconnect failed: ${e?.message || e}\n`)); }, backoff);
|
|
313
|
+
});
|
|
314
|
+
ws.addEventListener('error', (ev) => {
|
|
315
|
+
// The 'error' event fires before 'close'; we let 'close' drive
|
|
316
|
+
// the reconnect so we don't reconnect twice for one failure.
|
|
317
|
+
logger(`[slack] socket error: ${ev?.message || 'unknown'}\n`);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await connectOnce();
|
|
323
|
+
this._socketHandle = {
|
|
324
|
+
disconnect: async () => {
|
|
325
|
+
closed = true;
|
|
326
|
+
try { ws?.close(1000); } catch { /* best-effort */ }
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
return this._socketHandle;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Best-effort chat.delete — used by typing-indicator workflows where
|
|
333
|
+
// we post a placeholder and want to clean it up. Returns true on
|
|
334
|
+
// success, silent false otherwise.
|
|
335
|
+
async deleteMessage(channel, ts) {
|
|
336
|
+
if (!this._env.botToken || !channel || !ts) return false;
|
|
337
|
+
const url = `${this._env.apiBase.replace(/\/$/, '')}/chat.delete`;
|
|
338
|
+
try {
|
|
339
|
+
const res = await fetch(url, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: {
|
|
342
|
+
'Authorization': `Bearer ${this._env.botToken}`,
|
|
343
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify({ channel, ts }),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) return false;
|
|
348
|
+
const json = await res.json().catch(() => ({}));
|
|
349
|
+
return !!json.ok;
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Best-effort reaction add / remove. Returns true on success. Silent
|
|
356
|
+
// false on any failure (missing reactions:write scope, transport
|
|
357
|
+
// error, …) so callers can chain without noise.
|
|
358
|
+
async _reaction(action, channel, ts, name) {
|
|
359
|
+
if (!this._env.botToken || !channel || !ts) return false;
|
|
360
|
+
const endpoint = action === 'remove' ? 'reactions.remove' : 'reactions.add';
|
|
361
|
+
const url = `${this._env.apiBase.replace(/\/$/, '')}/${endpoint}`;
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetch(url, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: {
|
|
366
|
+
'Authorization': `Bearer ${this._env.botToken}`,
|
|
367
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
368
|
+
},
|
|
369
|
+
body: JSON.stringify({ channel, timestamp: ts, name }),
|
|
370
|
+
});
|
|
371
|
+
if (!res.ok) return false;
|
|
372
|
+
const json = await res.json().catch(() => ({}));
|
|
373
|
+
return !!json.ok;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async stop() {
|
|
380
|
+
if (this._socketHandle && typeof this._socketHandle.disconnect === 'function') {
|
|
381
|
+
try { await this._socketHandle.disconnect(); } catch { /* best-effort */ }
|
|
382
|
+
}
|
|
383
|
+
this._socketHandle = null;
|
|
384
|
+
await super.stop();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// In-memory channel used for tests and for any caller that wants to
|
|
2
|
+
// drive the daemon without a real transport. `inbox.push({ threadId,
|
|
3
|
+
// text, token? })` triggers the handler; replies land in `outbox`.
|
|
4
|
+
//
|
|
5
|
+
// The stub respects the same gate object the HTTP channel uses so
|
|
6
|
+
// auth-token + rate-limit assertions on the daemon's middleware chain
|
|
7
|
+
// can be exercised without round-tripping through TCP.
|
|
8
|
+
|
|
9
|
+
import { Channel } from './base.mjs';
|
|
10
|
+
|
|
11
|
+
export class StubChannel extends Channel {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('stub');
|
|
14
|
+
/** @type {Array<{ threadId: string, text: string, token?: string|null, key?: string|null }>} */
|
|
15
|
+
this.inbox = [];
|
|
16
|
+
/** @type {Array<{ threadId: string, text: string, error?: string }>} */
|
|
17
|
+
this.outbox = [];
|
|
18
|
+
this._pump = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start(handler, opts = {}) {
|
|
22
|
+
await super.start(handler, opts);
|
|
23
|
+
this._pump = setInterval(() => this._drain(), 5);
|
|
24
|
+
// unref so a hanging interval doesn't keep the process alive
|
|
25
|
+
if (typeof this._pump.unref === 'function') this._pump.unref();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _drain() {
|
|
29
|
+
while (this.inbox.length) {
|
|
30
|
+
const item = this.inbox.shift();
|
|
31
|
+
try {
|
|
32
|
+
const reply = await this._processInbound({
|
|
33
|
+
threadId: item.threadId,
|
|
34
|
+
text: item.text,
|
|
35
|
+
gateInput: { token: item.token, key: item.key },
|
|
36
|
+
});
|
|
37
|
+
this.outbox.push({ threadId: item.threadId, text: String(reply) });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
this.outbox.push({ threadId: item.threadId, text: '', error: err?.message || String(err), code: err?.code });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async send(threadId, text) {
|
|
45
|
+
this.outbox.push({ threadId, text: String(text) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop() {
|
|
49
|
+
if (this._pump) { clearInterval(this._pump); this._pump = null; }
|
|
50
|
+
await super.stop();
|
|
51
|
+
}
|
|
52
|
+
}
|