@myclaw163/openclaw-clawclaw 0.1.42
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 +220 -0
- package/README.zh.md +206 -0
- package/dist/index.d.mts +15 -0
- package/dist/index.mjs +1640 -0
- package/openclaw.plugin.json +177 -0
- package/package.json +66 -0
- package/scripts/postinstall.mjs +92 -0
- package/scripts/sync-manifest.mjs +104 -0
- package/skills/clawclaw/.gitkeep +1 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1640 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
5
|
+
import { delimiter, join, sep } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import * as Sentry from "@sentry/node";
|
|
9
|
+
//#region src/cli-resolve.ts
|
|
10
|
+
/**
|
|
11
|
+
* Shared package-bin resolver used by index.ts (clawclaw-cli) and stream.ts (openclaw).
|
|
12
|
+
*
|
|
13
|
+
* 不同包的 bin 文件布局不一样:
|
|
14
|
+
* - clawclaw-cli: <root>/bin/clawclaw-cli.mjs
|
|
15
|
+
* - openclaw: <root>/openclaw.mjs (无 bin 子目录)
|
|
16
|
+
* 所以 binRelPath 必须由调用方显式提供。
|
|
17
|
+
*/
|
|
18
|
+
function resolvePackageBin(importMetaUrl, packageName, binRelPath) {
|
|
19
|
+
try {
|
|
20
|
+
let dir = createRequire(importMetaUrl).resolve(packageName);
|
|
21
|
+
while (dir && dir !== join(dir, "..")) {
|
|
22
|
+
const next = dir.slice(0, dir.lastIndexOf(sep));
|
|
23
|
+
if (next === dir || !next) break;
|
|
24
|
+
dir = next;
|
|
25
|
+
if (existsSync(join(dir, "package.json"))) break;
|
|
26
|
+
}
|
|
27
|
+
const binJs = join(dir, binRelPath);
|
|
28
|
+
if (existsSync(binJs) && statSync(binJs).isFile()) return binJs;
|
|
29
|
+
} catch {}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/sentry.ts
|
|
34
|
+
/**
|
|
35
|
+
* sentry — monitor 生命周期事件上报
|
|
36
|
+
* ─────────────────────────────────────────
|
|
37
|
+
* 在 stream.ts 的 Instance 生命周期关键节点调用:
|
|
38
|
+
* - startInstance() → monitor.started gid=xxx (info)
|
|
39
|
+
* - NDJSON exit_reason:game_start → game.started gid=xxx (info)
|
|
40
|
+
* - NDJSON exit_reason:game_over → game.exited gid=xxx (info)
|
|
41
|
+
* - child close=0 → monitor.exited gid=xxx (info)
|
|
42
|
+
* - child close≠0 → monitor.crashed gid=xxx (warning)
|
|
43
|
+
* - restart 成功 → monitor.restarted gid=xxx (info)
|
|
44
|
+
* - restart 失败 → monitor.respawn_failed gid=xxx (error)
|
|
45
|
+
* - maxRestarts 耗尽 → monitor.restart_gave_up gid=xxx (error)
|
|
46
|
+
*
|
|
47
|
+
* 每条消息含 gid=<gameId> 前缀方便 Sentry 面板直接搜索;tags 中含 game_id / session_key。
|
|
48
|
+
* 按 game_id 分组即可区分每一局,按 session_key 区分不同 agent 会话。
|
|
49
|
+
*
|
|
50
|
+
* 设计要点:
|
|
51
|
+
* - 延迟初始化:仅在上报时才 Sentry.init(),未配置 DSN 时完全跳过
|
|
52
|
+
* - 所有 Sentry 调用外层 try/catch,失败不影响主流程
|
|
53
|
+
* - 统一用 captureMessage,按 level 区分 severity
|
|
54
|
+
* - 进程退出前调用 closeSentry() 确保缓冲区事件被 flush
|
|
55
|
+
*/
|
|
56
|
+
let dsn = null;
|
|
57
|
+
let initialized = false;
|
|
58
|
+
let initFailed = false;
|
|
59
|
+
let sentryLogger = null;
|
|
60
|
+
/** 从 plugin config 设置 DSN。调用方在 registerStreamTools 中调用。
|
|
61
|
+
* 优先级: openclaw.json 显式配 > manifest default > SENTRY_DSN 环境变量 > null(关闭) */
|
|
62
|
+
function configureSentry(dsnFromConfig, logger) {
|
|
63
|
+
dsn = dsnFromConfig?.trim() || process.env.SENTRY_DSN?.trim() || null;
|
|
64
|
+
sentryLogger = logger ?? null;
|
|
65
|
+
initialized = false;
|
|
66
|
+
initFailed = false;
|
|
67
|
+
}
|
|
68
|
+
/** 上报 monitor 生命周期事件。dsn 未配置时静默跳过。 */
|
|
69
|
+
function captureMonitorEvent(message, level, tags, extra) {
|
|
70
|
+
if (!dsn) return;
|
|
71
|
+
ensureInit();
|
|
72
|
+
if (!initialized) return;
|
|
73
|
+
try {
|
|
74
|
+
Sentry.withScope((scope) => {
|
|
75
|
+
setScopeContext(scope, tags, extra);
|
|
76
|
+
Sentry.captureMessage(`[monitor] ${message}`, level);
|
|
77
|
+
});
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 上报 terminal error(restart 耗尽 / respawn 失败)。
|
|
82
|
+
* 使用 captureException 让 Sentry 按 error type + stack 自动分组,
|
|
83
|
+
* 可在 Issues 面板中走 assign→resolve 工作流。
|
|
84
|
+
*/
|
|
85
|
+
function captureMonitorException(message, tags, extra) {
|
|
86
|
+
if (!dsn) return;
|
|
87
|
+
ensureInit();
|
|
88
|
+
if (!initialized) return;
|
|
89
|
+
try {
|
|
90
|
+
Sentry.withScope((scope) => {
|
|
91
|
+
setScopeContext(scope, tags, extra);
|
|
92
|
+
Sentry.captureException(/* @__PURE__ */ new Error(`[monitor] ${message}`));
|
|
93
|
+
});
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 关闭 Sentry,flush 缓冲区中的待发送事件。
|
|
98
|
+
* 应在进程退出/插件卸载时调用,避免 crash 事件丢失。
|
|
99
|
+
* 调用后仍可继续上报(下次 captureMonitorEvent 会重新 init)。
|
|
100
|
+
*/
|
|
101
|
+
async function closeSentry() {
|
|
102
|
+
if (!initialized) return;
|
|
103
|
+
try {
|
|
104
|
+
await Sentry.close(2e3);
|
|
105
|
+
} catch {}
|
|
106
|
+
initialized = false;
|
|
107
|
+
}
|
|
108
|
+
function setScopeContext(scope, tags, extra) {
|
|
109
|
+
if (tags) for (const [k, v] of Object.entries(tags)) scope.setTag(k, v);
|
|
110
|
+
if (extra) for (const [k, v] of Object.entries(extra)) scope.setExtra(k, v);
|
|
111
|
+
}
|
|
112
|
+
function ensureInit() {
|
|
113
|
+
if (initialized || !dsn || initFailed) return;
|
|
114
|
+
try {
|
|
115
|
+
Sentry.init({
|
|
116
|
+
dsn,
|
|
117
|
+
tracesSampleRate: 0,
|
|
118
|
+
defaultIntegrations: false,
|
|
119
|
+
integrations: [],
|
|
120
|
+
release: process.env.SENTRY_RELEASE ?? "openclaw-clawclaw",
|
|
121
|
+
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "production"
|
|
122
|
+
});
|
|
123
|
+
initialized = true;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
initFailed = true;
|
|
126
|
+
sentryLogger?.warn?.(`[clawclaw:sentry] Sentry.init failed (DSN may be invalid or unreachable): ${e?.message ?? e}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/stream.ts
|
|
131
|
+
/**
|
|
132
|
+
* stream — port of the former standalone `openclaw-monitor` plugin into
|
|
133
|
+
* openclaw-clawclaw, scoped to ClawClaw needs.
|
|
134
|
+
*
|
|
135
|
+
* Pattern: spawn a background command, capture its stdout/stderr line-by-line,
|
|
136
|
+
* debounce + batch them, then inject each batch back into the originating
|
|
137
|
+
* OpenClaw session as a new agent turn via `openclaw agent --deliver`.
|
|
138
|
+
*
|
|
139
|
+
* Public tools (registered by registerStreamTools):
|
|
140
|
+
* clawclaw_game_status — list running background streams + stats
|
|
141
|
+
* clawclaw_game_start — start `ccl game start` as a background stream (handles fresh start + reattach)
|
|
142
|
+
* clawclaw_game_stop — stop one or all running streams
|
|
143
|
+
*
|
|
144
|
+
* `ccl game start` now handles both fresh matchmaking and reattaching to an
|
|
145
|
+
* existing game (crash recovery). The old `ccl watch` streaming command has
|
|
146
|
+
* been retired — its spectator-URL replacement is auto-generated as clawclaw_watch.
|
|
147
|
+
*/
|
|
148
|
+
let abortAgentHarnessRun;
|
|
149
|
+
let resolveActiveEmbeddedRunSessionId;
|
|
150
|
+
import("openclaw/plugin-sdk/agent-harness-runtime").then((m) => {
|
|
151
|
+
abortAgentHarnessRun = m?.abortAgentHarnessRun;
|
|
152
|
+
resolveActiveEmbeddedRunSessionId = m?.resolveActiveEmbeddedRunSessionId;
|
|
153
|
+
}).catch(() => {});
|
|
154
|
+
const STREAM_DEFAULTS = {
|
|
155
|
+
agentTimeoutMs: 18e4,
|
|
156
|
+
debounceMs: 300,
|
|
157
|
+
minIntervalMs: 1e3,
|
|
158
|
+
maxBatchLines: 150,
|
|
159
|
+
maxQueueLines: 1e3,
|
|
160
|
+
maxInstances: 8,
|
|
161
|
+
emitLifecycleEvents: true,
|
|
162
|
+
autoRestart: true,
|
|
163
|
+
maxRestarts: 5,
|
|
164
|
+
restartBackoffBaseMs: 1e3,
|
|
165
|
+
restartBackoffMaxMs: 3e4,
|
|
166
|
+
maxConcurrentInjects: 1,
|
|
167
|
+
idleTimeoutMs: 12e4,
|
|
168
|
+
sessionNotFoundMaxRetries: 2,
|
|
169
|
+
maxBatchAgeMs: 3e3
|
|
170
|
+
};
|
|
171
|
+
const ID_REGEX = /^[A-Za-z0-9_-]{1,32}$/;
|
|
172
|
+
const instances = /* @__PURE__ */ new Map();
|
|
173
|
+
let streamConfig = {};
|
|
174
|
+
let streamLogger = null;
|
|
175
|
+
let resolvedOpenclawCli = null;
|
|
176
|
+
/** Resolver passed in by the host plugin to find ccl (for the auto/event-stream shortcuts). */
|
|
177
|
+
let resolvedCcl = null;
|
|
178
|
+
function cfg(key) {
|
|
179
|
+
const camelKey = `stream${key.charAt(0).toUpperCase()}${key.slice(1)}`;
|
|
180
|
+
const v = streamConfig[camelKey];
|
|
181
|
+
return v === void 0 || v === null ? STREAM_DEFAULTS[key] : v;
|
|
182
|
+
}
|
|
183
|
+
function generateId() {
|
|
184
|
+
return `mon-${randomUUID().slice(0, 8)}`;
|
|
185
|
+
}
|
|
186
|
+
function resolveOpenclawCli(override) {
|
|
187
|
+
const isWin = process.platform === "win32";
|
|
188
|
+
const exeNames = isWin ? [
|
|
189
|
+
"openclaw.cmd",
|
|
190
|
+
"openclaw.exe",
|
|
191
|
+
"openclaw"
|
|
192
|
+
] : ["openclaw"];
|
|
193
|
+
const candidates = [];
|
|
194
|
+
if (override) candidates.push(override);
|
|
195
|
+
if (!override) {
|
|
196
|
+
const binJs = resolvePackageBin(import.meta.url, "openclaw", "openclaw.mjs");
|
|
197
|
+
if (binJs) return {
|
|
198
|
+
exe: process.execPath,
|
|
199
|
+
prefixArgs: [binJs],
|
|
200
|
+
display: `${process.execPath} ${binJs}`
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const pathDirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
204
|
+
for (const dir of pathDirs) for (const exe of exeNames) candidates.push(`${dir}${sep}${exe}`);
|
|
205
|
+
for (const c of candidates) {
|
|
206
|
+
try {
|
|
207
|
+
if (!existsSync(c) || !statSync(c).isFile()) continue;
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const lower = c.toLowerCase();
|
|
212
|
+
if (lower.endsWith(".exe")) return {
|
|
213
|
+
exe: c,
|
|
214
|
+
prefixArgs: [],
|
|
215
|
+
display: c
|
|
216
|
+
};
|
|
217
|
+
if (lower.endsWith(".mjs") || lower.endsWith(".js")) return {
|
|
218
|
+
exe: process.execPath,
|
|
219
|
+
prefixArgs: [c],
|
|
220
|
+
display: `${process.execPath} ${c}`
|
|
221
|
+
};
|
|
222
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
223
|
+
const lastSep = c.lastIndexOf(sep);
|
|
224
|
+
const dir = lastSep >= 0 ? c.substring(0, lastSep) : ".";
|
|
225
|
+
const fname = c.substring(lastSep + 1).replace(/\.(cmd|bat)$/i, "");
|
|
226
|
+
const heuristicMjs = `${dir}${sep}node_modules${sep}${fname}${sep}${fname}.mjs`;
|
|
227
|
+
try {
|
|
228
|
+
if (existsSync(heuristicMjs) && statSync(heuristicMjs).isFile()) return {
|
|
229
|
+
exe: process.execPath,
|
|
230
|
+
prefixArgs: [heuristicMjs],
|
|
231
|
+
display: `${process.execPath} ${heuristicMjs}`
|
|
232
|
+
};
|
|
233
|
+
} catch {}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!isWin) return {
|
|
237
|
+
exe: c,
|
|
238
|
+
prefixArgs: [],
|
|
239
|
+
display: c
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function killTree(child) {
|
|
245
|
+
if (!child || child.killed || child.exitCode != null) return;
|
|
246
|
+
if (process.platform === "win32") {
|
|
247
|
+
try {
|
|
248
|
+
spawn("taskkill", [
|
|
249
|
+
"/T",
|
|
250
|
+
"/F",
|
|
251
|
+
"/PID",
|
|
252
|
+
String(child.pid)
|
|
253
|
+
], {
|
|
254
|
+
windowsHide: true,
|
|
255
|
+
stdio: "ignore"
|
|
256
|
+
});
|
|
257
|
+
} catch {}
|
|
258
|
+
try {
|
|
259
|
+
child.kill("SIGKILL");
|
|
260
|
+
} catch {}
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
process.kill(-child.pid, "SIGTERM");
|
|
264
|
+
} catch {
|
|
265
|
+
try {
|
|
266
|
+
child.kill("SIGTERM");
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
try {
|
|
271
|
+
process.kill(-child.pid, "SIGKILL");
|
|
272
|
+
} catch {
|
|
273
|
+
try {
|
|
274
|
+
child.kill("SIGKILL");
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
}, 5e3).unref();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
let injectingCount = 0;
|
|
281
|
+
const injectWaiters = [];
|
|
282
|
+
function acquireInjectSlot() {
|
|
283
|
+
const cap = Math.max(1, Number(cfg("maxConcurrentInjects")) || 1);
|
|
284
|
+
if (injectingCount < cap) {
|
|
285
|
+
injectingCount++;
|
|
286
|
+
return Promise.resolve();
|
|
287
|
+
}
|
|
288
|
+
return new Promise((res) => {
|
|
289
|
+
injectWaiters.push(() => {
|
|
290
|
+
injectingCount++;
|
|
291
|
+
res();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function releaseInjectSlot() {
|
|
296
|
+
injectingCount = Math.max(0, injectingCount - 1);
|
|
297
|
+
const next = injectWaiters.shift();
|
|
298
|
+
if (next) next();
|
|
299
|
+
}
|
|
300
|
+
async function spawnAgentInject(args) {
|
|
301
|
+
await acquireInjectSlot();
|
|
302
|
+
try {
|
|
303
|
+
return await spawnAgentInjectRaw(args);
|
|
304
|
+
} finally {
|
|
305
|
+
releaseInjectSlot();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function spawnAgentInjectRaw(args) {
|
|
309
|
+
const { sessionId, message, timeoutMs, channel, replyTo, replyAccount } = args;
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
if (!resolvedOpenclawCli) {
|
|
312
|
+
resolve({
|
|
313
|
+
ok: false,
|
|
314
|
+
code: "cli_missing",
|
|
315
|
+
error: "openclaw CLI not found"
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const cliArgs = [
|
|
320
|
+
...resolvedOpenclawCli.prefixArgs,
|
|
321
|
+
"agent",
|
|
322
|
+
"--session-id",
|
|
323
|
+
sessionId,
|
|
324
|
+
"--message",
|
|
325
|
+
message,
|
|
326
|
+
"--deliver",
|
|
327
|
+
"--json"
|
|
328
|
+
];
|
|
329
|
+
if (channel) cliArgs.push("--channel", channel);
|
|
330
|
+
if (replyTo) cliArgs.push("--reply-to", replyTo);
|
|
331
|
+
if (replyAccount) cliArgs.push("--reply-account", replyAccount);
|
|
332
|
+
let child;
|
|
333
|
+
try {
|
|
334
|
+
child = spawn(resolvedOpenclawCli.exe, cliArgs, {
|
|
335
|
+
stdio: [
|
|
336
|
+
"ignore",
|
|
337
|
+
"pipe",
|
|
338
|
+
"pipe"
|
|
339
|
+
],
|
|
340
|
+
windowsHide: true
|
|
341
|
+
});
|
|
342
|
+
} catch (e) {
|
|
343
|
+
resolve({
|
|
344
|
+
ok: false,
|
|
345
|
+
code: "spawn_failed",
|
|
346
|
+
error: e?.message ?? String(e)
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
let stdout = "";
|
|
351
|
+
let stderr = "";
|
|
352
|
+
let finished = false;
|
|
353
|
+
const finish = (result) => {
|
|
354
|
+
if (finished) return;
|
|
355
|
+
finished = true;
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
resolve(result);
|
|
358
|
+
};
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
try {
|
|
361
|
+
child.kill("SIGKILL");
|
|
362
|
+
} catch {}
|
|
363
|
+
finish({
|
|
364
|
+
ok: false,
|
|
365
|
+
code: "timeout",
|
|
366
|
+
error: `agent CLI did not return within ${timeoutMs}ms`
|
|
367
|
+
});
|
|
368
|
+
}, timeoutMs);
|
|
369
|
+
child.stdout?.on("data", (c) => {
|
|
370
|
+
stdout += c.toString();
|
|
371
|
+
});
|
|
372
|
+
child.stderr?.on("data", (c) => {
|
|
373
|
+
stderr += c.toString();
|
|
374
|
+
});
|
|
375
|
+
child.on("error", (e) => finish({
|
|
376
|
+
ok: false,
|
|
377
|
+
code: "spawn_error",
|
|
378
|
+
error: e.message
|
|
379
|
+
}));
|
|
380
|
+
child.on("close", (code) => {
|
|
381
|
+
if (code === 0) {
|
|
382
|
+
finish({
|
|
383
|
+
ok: true,
|
|
384
|
+
stdout,
|
|
385
|
+
stderr
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let errCode = "exit_nonzero";
|
|
390
|
+
let errMsg = stderr.trim() || stdout.trim() || `exit ${code}`;
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(stdout);
|
|
393
|
+
if (parsed && typeof parsed === "object") {
|
|
394
|
+
if (parsed.code) errCode = String(parsed.code);
|
|
395
|
+
if (parsed.error) errMsg = String(parsed.error);
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
if (errCode === "exit_nonzero") {
|
|
399
|
+
const lower = errMsg.toLowerCase();
|
|
400
|
+
if (lower.includes("session") && (lower.includes("not found") || lower.includes("unknown") || lower.includes("missing") || lower.includes("does not exist"))) errCode = "session_not_found";
|
|
401
|
+
}
|
|
402
|
+
finish({
|
|
403
|
+
ok: false,
|
|
404
|
+
code: errCode,
|
|
405
|
+
error: errMsg
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function emitPerfLog(inst, event, extra) {
|
|
411
|
+
const ts = Date.now();
|
|
412
|
+
const line = `[stream:${inst.id}] [perf] ${event} ts=${ts}${extra ? " " + extra : ""}`;
|
|
413
|
+
if (streamLogger?.info) streamLogger.info(line);
|
|
414
|
+
if (inst.perfLogFile) try {
|
|
415
|
+
appendFileSync(inst.perfLogFile, line + "\n");
|
|
416
|
+
} catch {}
|
|
417
|
+
}
|
|
418
|
+
function armIdleWatchdog(inst) {
|
|
419
|
+
if (inst.idleTimeoutMs <= 0 || inst.stopped) return;
|
|
420
|
+
if (inst.idleWatchdogTimer) clearTimeout(inst.idleWatchdogTimer);
|
|
421
|
+
inst.idleWatchdogTimer = setTimeout(() => onIdleTimeout(inst), inst.idleTimeoutMs);
|
|
422
|
+
inst.idleWatchdogTimer.unref?.();
|
|
423
|
+
}
|
|
424
|
+
function onIdleTimeout(inst) {
|
|
425
|
+
inst.idleWatchdogTimer = null;
|
|
426
|
+
if (inst.stopped) return;
|
|
427
|
+
const idleMs = Date.now() - inst.lastLineAt;
|
|
428
|
+
if (idleMs < inst.idleTimeoutMs) {
|
|
429
|
+
const remaining = Math.max(1, inst.idleTimeoutMs - idleMs);
|
|
430
|
+
inst.idleWatchdogTimer = setTimeout(() => onIdleTimeout(inst), remaining);
|
|
431
|
+
inst.idleWatchdogTimer.unref?.();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!cfg("emitLifecycleEvents")) return;
|
|
435
|
+
const idleSec = Math.round(idleMs / 1e3);
|
|
436
|
+
const msg = `${inst.prefix}[STREAM_IDLE id=${inst.id} idleSec=${idleSec} pid=${inst.child.pid ?? "?"}]`;
|
|
437
|
+
spawnAgentInject({
|
|
438
|
+
sessionId: inst.capturedSessionId,
|
|
439
|
+
message: msg,
|
|
440
|
+
timeoutMs: cfg("agentTimeoutMs"),
|
|
441
|
+
channel: inst.capturedChannel,
|
|
442
|
+
replyTo: inst.capturedReplyTo,
|
|
443
|
+
replyAccount: inst.capturedReplyAccount
|
|
444
|
+
}).catch((e) => {
|
|
445
|
+
streamLogger?.warn?.(`[stream:${inst.id}] STREAM_IDLE inject failed: ${e?.message ?? e}`);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const PREEMPT_REGEX = /"trigger"\s*:\s*"(speech_your_turn|vote_phase_start)"/;
|
|
449
|
+
const PREEMPT_DEDUP_MS = 1e3;
|
|
450
|
+
function matchPreemptTrigger(line) {
|
|
451
|
+
const m = PREEMPT_REGEX.exec(line);
|
|
452
|
+
return m ? m[1] : null;
|
|
453
|
+
}
|
|
454
|
+
const GAME_EVENT_REGEX = /"exit_reason"\s*:\s*\[?"(game_start|game_over)"/;
|
|
455
|
+
function matchGameEvent(line) {
|
|
456
|
+
const m = GAME_EVENT_REGEX.exec(line);
|
|
457
|
+
return m ? m[1] : null;
|
|
458
|
+
}
|
|
459
|
+
const SERVER_GAME_ID_REGEX = /"game_id"\s*:\s*"([^"]+)"/;
|
|
460
|
+
/** 从 NDJSON 行中提取服务端 game_id,替换本地生成的临时 id */
|
|
461
|
+
function captureServerGameId(inst, line) {
|
|
462
|
+
const m = SERVER_GAME_ID_REGEX.exec(line);
|
|
463
|
+
if (m && m[1] !== inst.gameId) {
|
|
464
|
+
const isFirst = !inst.gameId;
|
|
465
|
+
inst.gameId = m[1];
|
|
466
|
+
if (isFirst) captureMonitorEvent(`monitor.started gid=${inst.gameId}`, "info", {
|
|
467
|
+
monitor_id: inst.id,
|
|
468
|
+
game_id: inst.gameId,
|
|
469
|
+
command: inst.command,
|
|
470
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
471
|
+
}, {
|
|
472
|
+
pid: inst.child.pid,
|
|
473
|
+
autoRestart: inst.spawnOpts.autoRestart,
|
|
474
|
+
maxRestarts: inst.spawnOpts.maxRestarts
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function preemptCancel(inst, trigger) {
|
|
479
|
+
if (!abortAgentHarnessRun) {
|
|
480
|
+
emitPerfLog(inst, "preempt_skipped", `reason=sdk_unavailable`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
if (trigger === inst.lastPreemptTrigger && now - inst.lastPreemptAt < PREEMPT_DEDUP_MS) {
|
|
485
|
+
emitPerfLog(inst, "preempt_deduped", `trigger=${trigger} sinceLast=${now - inst.lastPreemptAt}ms`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
inst.lastPreemptTrigger = trigger;
|
|
489
|
+
inst.lastPreemptAt = now;
|
|
490
|
+
let sessionId;
|
|
491
|
+
if (inst.capturedSessionKey && resolveActiveEmbeddedRunSessionId) try {
|
|
492
|
+
sessionId = resolveActiveEmbeddedRunSessionId(inst.capturedSessionKey);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
emitPerfLog(inst, "preempt_resolve_failed", `key=${inst.capturedSessionKey} err=${e?.message ?? e}`);
|
|
495
|
+
}
|
|
496
|
+
sessionId ??= inst.capturedSessionId;
|
|
497
|
+
if (!sessionId) {
|
|
498
|
+
emitPerfLog(inst, "preempt_skipped", `reason=no_session`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
let aborted = false;
|
|
502
|
+
try {
|
|
503
|
+
aborted = abortAgentHarnessRun(sessionId) === true;
|
|
504
|
+
} catch (e) {
|
|
505
|
+
emitPerfLog(inst, "preempt_abort_failed", `sessionId=${sessionId} err=${e?.message ?? e}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
emitPerfLog(inst, "preempt_done", `sessionId=${sessionId} aborted=${aborted}`);
|
|
509
|
+
}
|
|
510
|
+
function enqueueLine(inst, line) {
|
|
511
|
+
if (inst.stopped) return;
|
|
512
|
+
if (inst.filterRegex) {
|
|
513
|
+
let matched = true;
|
|
514
|
+
try {
|
|
515
|
+
matched = inst.filterRegex.test(String(line));
|
|
516
|
+
} catch (e) {
|
|
517
|
+
streamLogger?.warn?.(`[stream:${inst.id}] filter regex threw, passing line through: ${e?.message ?? e}`);
|
|
518
|
+
}
|
|
519
|
+
if (!matched) return;
|
|
520
|
+
}
|
|
521
|
+
captureServerGameId(inst, line);
|
|
522
|
+
const trigger = matchPreemptTrigger(line);
|
|
523
|
+
if (trigger) {
|
|
524
|
+
preemptCancel(inst, trigger);
|
|
525
|
+
inst.flushAllPending = true;
|
|
526
|
+
}
|
|
527
|
+
const gameEvent = matchGameEvent(line);
|
|
528
|
+
if (gameEvent === "game_start") captureMonitorEvent(`game.started gid=${inst.gameId}`, "info", {
|
|
529
|
+
monitor_id: inst.id,
|
|
530
|
+
game_id: inst.gameId,
|
|
531
|
+
command: inst.command,
|
|
532
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
533
|
+
});
|
|
534
|
+
else if (gameEvent === "game_over") captureMonitorEvent(`game.exited gid=${inst.gameId}`, "info", {
|
|
535
|
+
monitor_id: inst.id,
|
|
536
|
+
game_id: inst.gameId,
|
|
537
|
+
command: inst.command,
|
|
538
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
539
|
+
});
|
|
540
|
+
inst.lastLineAt = Date.now();
|
|
541
|
+
armIdleWatchdog(inst);
|
|
542
|
+
if (inst.pendingLines.length === 0) inst.batchStartedAt = Date.now();
|
|
543
|
+
inst.pendingLines.push(line);
|
|
544
|
+
if (inst.pendingLines.length > inst.maxQueueLines) {
|
|
545
|
+
const drop = inst.pendingLines.length - inst.maxQueueLines;
|
|
546
|
+
inst.pendingLines.splice(0, drop);
|
|
547
|
+
inst.stats.dropped += drop;
|
|
548
|
+
streamLogger?.warn?.(`[stream:${inst.id}] backpressure: dropped ${drop} oldest lines (cap ${inst.maxQueueLines})`);
|
|
549
|
+
}
|
|
550
|
+
emitPerfLog(inst, "line_enqueued", `delta=${Date.now() - inst.lastLineTs}ms pending=${inst.pendingLines.length}`);
|
|
551
|
+
const ageExceeded = Date.now() - inst.batchStartedAt >= inst.maxBatchAgeMs;
|
|
552
|
+
if (trigger || inst.pendingLines.length >= inst.maxBatchLines || ageExceeded) flushNow(inst);
|
|
553
|
+
else {
|
|
554
|
+
if (inst.flushTimer) clearTimeout(inst.flushTimer);
|
|
555
|
+
inst.flushTimer = setTimeout(() => flushNow(inst), inst.debounceMs);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function flushNow(inst) {
|
|
559
|
+
if (inst.flushTimer) {
|
|
560
|
+
clearTimeout(inst.flushTimer);
|
|
561
|
+
inst.flushTimer = null;
|
|
562
|
+
}
|
|
563
|
+
if (inst.stopped) return;
|
|
564
|
+
if (inst.pendingLines.length === 0) return;
|
|
565
|
+
if (inst.inFlight) {
|
|
566
|
+
inst.flushTimer = setTimeout(() => flushNow(inst), inst.debounceMs);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const take = inst.flushAllPending ? inst.pendingLines.length : inst.maxBatchLines;
|
|
570
|
+
inst.flushAllPending = false;
|
|
571
|
+
const lines = inst.pendingLines.splice(0, take);
|
|
572
|
+
inst.batchStartedAt = inst.pendingLines.length === 0 ? 0 : Date.now();
|
|
573
|
+
inst.flushStartTs = Date.now();
|
|
574
|
+
emitPerfLog(inst, "flush_start", `deltaFromLine=${inst.flushStartTs - inst.lastLineTs}ms batch=${lines.length} pending=${inst.pendingLines.length}`);
|
|
575
|
+
dispatch(inst, lines).catch((e) => {
|
|
576
|
+
streamLogger?.warn?.(`[stream:${inst.id}] dispatch crashed: ${e?.message ?? e}`);
|
|
577
|
+
});
|
|
578
|
+
if (!inst.stopped && inst.pendingLines.length > 0) inst.flushTimer = setTimeout(() => flushNow(inst), inst.debounceMs);
|
|
579
|
+
}
|
|
580
|
+
async function acquireSerial(inst) {
|
|
581
|
+
if (!inst.inFlight) {
|
|
582
|
+
inst.inFlight = true;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
await new Promise((res) => inst.inFlightWaiters.push(res));
|
|
586
|
+
inst.inFlight = true;
|
|
587
|
+
}
|
|
588
|
+
function releaseSerial(inst) {
|
|
589
|
+
inst.inFlight = false;
|
|
590
|
+
const next = inst.inFlightWaiters.shift();
|
|
591
|
+
if (next) next();
|
|
592
|
+
}
|
|
593
|
+
/** Fire-and-forget inject that respects the per-instance serial lock. */
|
|
594
|
+
function injectSerialized(inst, message) {
|
|
595
|
+
acquireSerial(inst).then(() => spawnAgentInject({
|
|
596
|
+
sessionId: inst.capturedSessionId,
|
|
597
|
+
message,
|
|
598
|
+
timeoutMs: cfg("agentTimeoutMs"),
|
|
599
|
+
channel: inst.capturedChannel,
|
|
600
|
+
replyTo: inst.capturedReplyTo,
|
|
601
|
+
replyAccount: inst.capturedReplyAccount
|
|
602
|
+
})).finally(() => releaseSerial(inst)).catch(() => {});
|
|
603
|
+
}
|
|
604
|
+
const MAX_DISPATCH_RETRIES = 1;
|
|
605
|
+
/** Windows CreateProcess 命令行上限 ~32KB,留余量设为 24KB。超过此值自动分片。 */
|
|
606
|
+
const MAX_MESSAGE_BYTES = 24e3;
|
|
607
|
+
/** 将 message 按近似字节数拆分为多组 lines,尽量不切割单行。 */
|
|
608
|
+
function splitLinesByBytes(lines, maxBytes, prefixLen) {
|
|
609
|
+
const chunks = [];
|
|
610
|
+
let cur = [];
|
|
611
|
+
let curBytes = 0;
|
|
612
|
+
for (const line of lines) {
|
|
613
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + prefixLen + 1;
|
|
614
|
+
if (cur.length > 0 && curBytes + lineBytes > maxBytes) {
|
|
615
|
+
chunks.push(cur);
|
|
616
|
+
cur = [];
|
|
617
|
+
curBytes = 0;
|
|
618
|
+
}
|
|
619
|
+
cur.push(line);
|
|
620
|
+
curBytes += lineBytes;
|
|
621
|
+
}
|
|
622
|
+
if (cur.length > 0) chunks.push(cur);
|
|
623
|
+
return chunks;
|
|
624
|
+
}
|
|
625
|
+
/** 单次注入(含重试),返回注入结果并更新 stats。调用方负责 serial lock。 */
|
|
626
|
+
async function injectBatch(inst, lines) {
|
|
627
|
+
const message = lines.map((l) => `${inst.prefix}${l}`).join("\n");
|
|
628
|
+
let result = {
|
|
629
|
+
ok: false,
|
|
630
|
+
code: "not_attempted",
|
|
631
|
+
error: ""
|
|
632
|
+
};
|
|
633
|
+
for (let attempt = 0; attempt <= MAX_DISPATCH_RETRIES; attempt++) {
|
|
634
|
+
const injectStart = Date.now();
|
|
635
|
+
result = await spawnAgentInject({
|
|
636
|
+
sessionId: inst.capturedSessionId,
|
|
637
|
+
message,
|
|
638
|
+
timeoutMs: cfg("agentTimeoutMs"),
|
|
639
|
+
channel: inst.capturedChannel,
|
|
640
|
+
replyTo: inst.capturedReplyTo,
|
|
641
|
+
replyAccount: inst.capturedReplyAccount
|
|
642
|
+
});
|
|
643
|
+
emitPerfLog(inst, "inject_done", `ok=${result.ok} code=${result.code} duration=${Date.now() - injectStart}ms attempt=${attempt} totalFromFlush=${Date.now() - inst.flushStartTs}ms`);
|
|
644
|
+
inst.lastDispatchAt = Date.now();
|
|
645
|
+
if (result.ok) break;
|
|
646
|
+
if (result.code === "session_not_found") break;
|
|
647
|
+
if (attempt < MAX_DISPATCH_RETRIES) streamLogger?.warn?.(`[stream:${inst.id}] dispatch retry ${attempt + 1}/${MAX_DISPATCH_RETRIES}: ${result.code} ${result.error}`);
|
|
648
|
+
}
|
|
649
|
+
if (result.ok) {
|
|
650
|
+
inst.stats.batches += 1;
|
|
651
|
+
inst.stats.lines += lines.length;
|
|
652
|
+
inst.stats.lastError = null;
|
|
653
|
+
inst.sessionNotFoundCount = 0;
|
|
654
|
+
} else {
|
|
655
|
+
inst.stats.lastError = `${result.code}: ${result.error}`;
|
|
656
|
+
streamLogger?.warn?.(`[stream:${inst.id}] inject failed: ${result.code} ${result.error}`);
|
|
657
|
+
inst.stats.dropped += lines.length;
|
|
658
|
+
if (result.code === "session_not_found") {
|
|
659
|
+
inst.sessionNotFoundCount += 1;
|
|
660
|
+
if (inst.sessionNotFoundCount > inst.sessionNotFoundMaxRetries) {
|
|
661
|
+
streamLogger?.warn?.(`[stream:${inst.id}] session_not_found ${inst.sessionNotFoundCount}x consecutively, stopping`);
|
|
662
|
+
stopInstance(inst.id, {
|
|
663
|
+
reason: "session_not_found",
|
|
664
|
+
emitLifecycle: false
|
|
665
|
+
}).catch(() => {});
|
|
666
|
+
}
|
|
667
|
+
} else inst.sessionNotFoundCount = 0;
|
|
668
|
+
}
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
671
|
+
async function dispatch(inst, lines) {
|
|
672
|
+
await acquireSerial(inst);
|
|
673
|
+
emitPerfLog(inst, "serial_acquired", `waitMs=${Date.now() - inst.flushStartTs}ms`);
|
|
674
|
+
try {
|
|
675
|
+
if (inst.stopped) {
|
|
676
|
+
inst.pendingLines = [...lines, ...inst.pendingLines];
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const wait = inst.lastDispatchAt + inst.minIntervalMs - Date.now();
|
|
680
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
681
|
+
emitPerfLog(inst, "interval_waited", `delta=${Date.now() - inst.flushStartTs}ms waited=${Math.max(0, wait)}ms`);
|
|
682
|
+
const chunks = splitLinesByBytes(lines, MAX_MESSAGE_BYTES, Buffer.byteLength(inst.prefix, "utf-8"));
|
|
683
|
+
for (let ci = 0; ci < chunks.length; ci++) {
|
|
684
|
+
if (inst.stopped) break;
|
|
685
|
+
if (ci > 0) {
|
|
686
|
+
const chunkWait = inst.lastDispatchAt + inst.minIntervalMs - Date.now();
|
|
687
|
+
if (chunkWait > 0) await new Promise((r) => setTimeout(r, chunkWait));
|
|
688
|
+
}
|
|
689
|
+
const result = await injectBatch(inst, chunks[ci]);
|
|
690
|
+
if (chunks.length > 1) emitPerfLog(inst, "chunk_injected", `chunk=${ci + 1}/${chunks.length} lines=${chunks[ci].length}`);
|
|
691
|
+
if (!result.ok && result.code === "session_not_found") break;
|
|
692
|
+
}
|
|
693
|
+
} finally {
|
|
694
|
+
releaseSerial(inst);
|
|
695
|
+
if (!inst.stopped && inst.pendingLines.length > 0 && !inst.flushTimer) inst.flushTimer = setTimeout(() => flushNow(inst), inst.debounceMs);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function stopInstance(id, opts = {}) {
|
|
699
|
+
const { reason = "manual", emitLifecycle = true } = opts;
|
|
700
|
+
const inst = instances.get(id);
|
|
701
|
+
if (!inst) return null;
|
|
702
|
+
if (inst.stopped) return inst;
|
|
703
|
+
inst.stopped = true;
|
|
704
|
+
inst.explicitStop = true;
|
|
705
|
+
if (inst.restartTimer) {
|
|
706
|
+
clearTimeout(inst.restartTimer);
|
|
707
|
+
inst.restartTimer = null;
|
|
708
|
+
}
|
|
709
|
+
if (inst.flushTimer) {
|
|
710
|
+
clearTimeout(inst.flushTimer);
|
|
711
|
+
inst.flushTimer = null;
|
|
712
|
+
}
|
|
713
|
+
if (inst.idleWatchdogTimer) {
|
|
714
|
+
clearTimeout(inst.idleWatchdogTimer);
|
|
715
|
+
inst.idleWatchdogTimer = null;
|
|
716
|
+
}
|
|
717
|
+
if (inst.stdoutRl) {
|
|
718
|
+
inst.stdoutRl.close();
|
|
719
|
+
inst.stdoutRl = null;
|
|
720
|
+
}
|
|
721
|
+
if (inst.stderrRl) {
|
|
722
|
+
inst.stderrRl.close();
|
|
723
|
+
inst.stderrRl = null;
|
|
724
|
+
}
|
|
725
|
+
const drained = inst.pendingLines.splice(0, inst.pendingLines.length);
|
|
726
|
+
killTree(inst.child);
|
|
727
|
+
await new Promise((res) => {
|
|
728
|
+
if (inst.child.exitCode != null || inst.child.signalCode != null) return res();
|
|
729
|
+
let done = false;
|
|
730
|
+
const finish = () => {
|
|
731
|
+
if (!done) {
|
|
732
|
+
done = true;
|
|
733
|
+
res();
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
inst.child.once("close", finish);
|
|
737
|
+
setTimeout(finish, 6e3).unref();
|
|
738
|
+
});
|
|
739
|
+
instances.delete(id);
|
|
740
|
+
if (inst.perfLogFile) try {
|
|
741
|
+
const durSec = ((Date.now() - inst.startedAt) / 1e3).toFixed(1);
|
|
742
|
+
appendFileSync(inst.perfLogFile, `[SESSION_END id=${id} reason=${reason} ranSec=${durSec} batches=${inst.stats.batches} lines=${inst.stats.lines} dropped=${inst.stats.dropped} ts=${Date.now()}]
|
|
743
|
+
`);
|
|
744
|
+
} catch {}
|
|
745
|
+
const emitTail = emitLifecycle && cfg("emitLifecycleEvents") && reason !== "gateway_stop";
|
|
746
|
+
if (drained.length > 0 || emitTail) {
|
|
747
|
+
let message = drained.map((l) => `${inst.prefix}${l}`).join("\n");
|
|
748
|
+
if (emitTail) {
|
|
749
|
+
const dur = ((Date.now() - inst.startedAt) / 1e3).toFixed(1);
|
|
750
|
+
const tail = `${inst.prefix}[STREAM_STOPPED id=${id} reason=${reason} ranSec=${dur} batches=${inst.stats.batches} lines=${inst.stats.lines}]`;
|
|
751
|
+
message = message ? message + "\n" + tail : tail;
|
|
752
|
+
}
|
|
753
|
+
injectSerialized(inst, message);
|
|
754
|
+
}
|
|
755
|
+
return inst;
|
|
756
|
+
}
|
|
757
|
+
function spawnChildProc(opts) {
|
|
758
|
+
return spawn(opts.command, opts.args, {
|
|
759
|
+
stdio: [
|
|
760
|
+
"ignore",
|
|
761
|
+
"pipe",
|
|
762
|
+
"pipe"
|
|
763
|
+
],
|
|
764
|
+
shell: opts.useShell,
|
|
765
|
+
windowsHide: true,
|
|
766
|
+
cwd: opts.cwd || process.cwd(),
|
|
767
|
+
env: {
|
|
768
|
+
...process.env,
|
|
769
|
+
NO_COLOR: "1",
|
|
770
|
+
FORCE_COLOR: "0",
|
|
771
|
+
...opts.env ?? {}
|
|
772
|
+
},
|
|
773
|
+
detached: process.platform !== "win32"
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
function attachChildListeners(inst) {
|
|
777
|
+
const child = inst.child;
|
|
778
|
+
const id = inst.id;
|
|
779
|
+
if (inst.stdoutRl) {
|
|
780
|
+
inst.stdoutRl.close();
|
|
781
|
+
inst.stdoutRl = null;
|
|
782
|
+
}
|
|
783
|
+
if (inst.stderrRl) {
|
|
784
|
+
inst.stderrRl.close();
|
|
785
|
+
inst.stderrRl = null;
|
|
786
|
+
}
|
|
787
|
+
if (child.stdout) {
|
|
788
|
+
const rl = createInterface({ input: child.stdout });
|
|
789
|
+
inst.stdoutRl = rl;
|
|
790
|
+
rl.on("line", (line) => {
|
|
791
|
+
inst.lastLineTs = Date.now();
|
|
792
|
+
emitPerfLog(inst, "line_recv");
|
|
793
|
+
enqueueLine(inst, line);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
if (child.stderr) {
|
|
797
|
+
const rl = createInterface({ input: child.stderr });
|
|
798
|
+
inst.stderrRl = rl;
|
|
799
|
+
rl.on("line", (line) => {
|
|
800
|
+
inst.lastLineTs = Date.now();
|
|
801
|
+
emitPerfLog(inst, "line_recv", "stream=stderr");
|
|
802
|
+
enqueueLine(inst, `[stderr] ${line}`);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
child.on("error", (e) => {
|
|
806
|
+
streamLogger?.warn?.(`[stream:${id}] child error: ${e?.message ?? e}`);
|
|
807
|
+
});
|
|
808
|
+
child.on("close", (code, signal) => {
|
|
809
|
+
if (inst.stopped) return;
|
|
810
|
+
const cleanExit = code === 0;
|
|
811
|
+
if (inst.autoRestart && !inst.explicitStop && !cleanExit && inst.restartTimestamps.length < inst.maxRestarts) {
|
|
812
|
+
const attempt = inst.restartTimestamps.length + 1;
|
|
813
|
+
inst.restartTimestamps.push(Date.now());
|
|
814
|
+
inst.stats.restarts += 1;
|
|
815
|
+
captureMonitorEvent(`monitor.crashed gid=${inst.gameId} attempt=${attempt}/${inst.maxRestarts}`, "warning", {
|
|
816
|
+
monitor_id: id,
|
|
817
|
+
game_id: inst.gameId,
|
|
818
|
+
command: inst.command,
|
|
819
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
820
|
+
}, {
|
|
821
|
+
code,
|
|
822
|
+
signal,
|
|
823
|
+
attempt,
|
|
824
|
+
maxRestarts: inst.maxRestarts,
|
|
825
|
+
pid: child.pid
|
|
826
|
+
});
|
|
827
|
+
const backoff = Math.min(inst.restartBackoffBaseMs * Math.pow(2, attempt - 1), inst.restartBackoffMaxMs);
|
|
828
|
+
if (inst.flushTimer) {
|
|
829
|
+
clearTimeout(inst.flushTimer);
|
|
830
|
+
inst.flushTimer = null;
|
|
831
|
+
}
|
|
832
|
+
if (inst.idleWatchdogTimer) {
|
|
833
|
+
clearTimeout(inst.idleWatchdogTimer);
|
|
834
|
+
inst.idleWatchdogTimer = null;
|
|
835
|
+
}
|
|
836
|
+
if (cfg("emitLifecycleEvents")) {
|
|
837
|
+
const msg = `${inst.prefix}[STREAM_RESTART id=${id} code=${code ?? signal ?? "?"} attempt=${attempt}/${inst.maxRestarts} delayMs=${backoff}]`;
|
|
838
|
+
spawnAgentInject({
|
|
839
|
+
sessionId: inst.capturedSessionId,
|
|
840
|
+
message: msg,
|
|
841
|
+
timeoutMs: cfg("agentTimeoutMs"),
|
|
842
|
+
channel: inst.capturedChannel,
|
|
843
|
+
replyTo: inst.capturedReplyTo,
|
|
844
|
+
replyAccount: inst.capturedReplyAccount
|
|
845
|
+
}).catch((e) => {
|
|
846
|
+
streamLogger?.warn?.(`[stream:${inst.id}] STREAM_RESTART inject failed: ${e?.message ?? e}`);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
inst.restartTimer = setTimeout(() => {
|
|
850
|
+
inst.restartTimer = null;
|
|
851
|
+
if (inst.explicitStop || inst.stopped) return;
|
|
852
|
+
try {
|
|
853
|
+
inst.child = spawnChildProc(inst.spawnOpts);
|
|
854
|
+
inst.startedAt = Date.now();
|
|
855
|
+
inst.lastLineAt = Date.now();
|
|
856
|
+
attachChildListeners(inst);
|
|
857
|
+
armIdleWatchdog(inst);
|
|
858
|
+
captureMonitorEvent(`monitor.restarted gid=${inst.gameId} attempt=${attempt}/${inst.maxRestarts}`, "info", {
|
|
859
|
+
monitor_id: id,
|
|
860
|
+
game_id: inst.gameId,
|
|
861
|
+
command: inst.command,
|
|
862
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
863
|
+
}, {
|
|
864
|
+
attempt,
|
|
865
|
+
maxRestarts: inst.maxRestarts,
|
|
866
|
+
pid: inst.child.pid
|
|
867
|
+
});
|
|
868
|
+
} catch (e) {
|
|
869
|
+
streamLogger?.warn?.(`[stream:${id}] respawn failed: ${e?.message ?? e}`);
|
|
870
|
+
inst.stopped = true;
|
|
871
|
+
captureMonitorException(`monitor.respawn_failed gid=${inst.gameId} attempt=${attempt}/${inst.maxRestarts}`, {
|
|
872
|
+
monitor_id: id,
|
|
873
|
+
game_id: inst.gameId,
|
|
874
|
+
command: inst.command,
|
|
875
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
876
|
+
}, {
|
|
877
|
+
attempt,
|
|
878
|
+
maxRestarts: inst.maxRestarts,
|
|
879
|
+
error: e?.message ?? String(e)
|
|
880
|
+
});
|
|
881
|
+
if (cfg("emitLifecycleEvents")) {
|
|
882
|
+
const failMsg = `${inst.prefix}[STREAM_RESTART_FAILED id=${id} reason=spawn_error error=${e?.message ?? e}]`;
|
|
883
|
+
spawnAgentInject({
|
|
884
|
+
sessionId: inst.capturedSessionId,
|
|
885
|
+
message: failMsg,
|
|
886
|
+
timeoutMs: cfg("agentTimeoutMs"),
|
|
887
|
+
channel: inst.capturedChannel,
|
|
888
|
+
replyTo: inst.capturedReplyTo,
|
|
889
|
+
replyAccount: inst.capturedReplyAccount
|
|
890
|
+
}).catch((e2) => {
|
|
891
|
+
streamLogger?.warn?.(`[stream:${inst.id}] STREAM_RESTART_FAILED inject failed: ${e2?.message ?? e2}`);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
setTimeout(() => instances.delete(id), 200).unref();
|
|
895
|
+
}
|
|
896
|
+
}, backoff);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (inst.flushTimer) {
|
|
900
|
+
clearTimeout(inst.flushTimer);
|
|
901
|
+
inst.flushTimer = null;
|
|
902
|
+
}
|
|
903
|
+
if (inst.idleWatchdogTimer) {
|
|
904
|
+
clearTimeout(inst.idleWatchdogTimer);
|
|
905
|
+
inst.idleWatchdogTimer = null;
|
|
906
|
+
}
|
|
907
|
+
const drained = inst.pendingLines.splice(0, inst.pendingLines.length);
|
|
908
|
+
inst.stopped = true;
|
|
909
|
+
const emitExit = cfg("emitLifecycleEvents");
|
|
910
|
+
if (drained.length > 0 || emitExit) {
|
|
911
|
+
let message = drained.map((l) => `${inst.prefix}${l}`).join("\n");
|
|
912
|
+
if (emitExit) {
|
|
913
|
+
const gaveUp = inst.autoRestart && !inst.explicitStop && inst.restartTimestamps.length >= inst.maxRestarts && inst.restartTimestamps.length > 0;
|
|
914
|
+
if (gaveUp) captureMonitorException(`monitor.restart_gave_up gid=${inst.gameId} attempts=${inst.maxRestarts}`, {
|
|
915
|
+
monitor_id: id,
|
|
916
|
+
game_id: inst.gameId,
|
|
917
|
+
command: inst.command,
|
|
918
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
919
|
+
}, {
|
|
920
|
+
lastCode: code ?? signal,
|
|
921
|
+
totalAttempts: inst.maxRestarts,
|
|
922
|
+
pid: child.pid
|
|
923
|
+
});
|
|
924
|
+
else if (cleanExit) {
|
|
925
|
+
const ranSec = ((Date.now() - inst.startedAt) / 1e3).toFixed(1);
|
|
926
|
+
captureMonitorEvent(`monitor.exited gid=${inst.gameId} code=0 ranSec=${ranSec}`, "info", {
|
|
927
|
+
monitor_id: id,
|
|
928
|
+
game_id: inst.gameId,
|
|
929
|
+
command: inst.command,
|
|
930
|
+
session_key: inst.capturedSessionKey ?? ""
|
|
931
|
+
}, {
|
|
932
|
+
code: 0,
|
|
933
|
+
ranSec,
|
|
934
|
+
batches: inst.stats.batches,
|
|
935
|
+
lines: inst.stats.lines,
|
|
936
|
+
pid: child.pid
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
const tail = gaveUp ? `${inst.prefix}[STREAM_RESTART_GAVE_UP id=${id} attempts=${inst.maxRestarts} lastCode=${code ?? signal ?? "?"}]` : `${inst.prefix}[STREAM_EXIT id=${id} code=${code ?? signal ?? "?"}]`;
|
|
940
|
+
message = message ? message + "\n" + tail : tail;
|
|
941
|
+
}
|
|
942
|
+
injectSerialized(inst, message);
|
|
943
|
+
}
|
|
944
|
+
setTimeout(() => instances.delete(id), 200).unref();
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
function startInstance(opts) {
|
|
948
|
+
const child = spawnChildProc(opts);
|
|
949
|
+
const inst = {
|
|
950
|
+
id: opts.id,
|
|
951
|
+
command: opts.command,
|
|
952
|
+
args: opts.args,
|
|
953
|
+
cwd: opts.cwd || process.cwd(),
|
|
954
|
+
filterRegex: opts.filterRegex,
|
|
955
|
+
prefix: opts.prefix,
|
|
956
|
+
debounceMs: opts.debounceMs,
|
|
957
|
+
minIntervalMs: opts.minIntervalMs,
|
|
958
|
+
maxBatchLines: opts.maxBatchLines,
|
|
959
|
+
maxQueueLines: opts.maxQueueLines,
|
|
960
|
+
capturedSessionId: opts.capturedSessionId,
|
|
961
|
+
capturedSessionKey: opts.capturedSessionKey,
|
|
962
|
+
capturedChannel: opts.capturedChannel,
|
|
963
|
+
capturedReplyTo: opts.capturedReplyTo,
|
|
964
|
+
capturedReplyAccount: opts.capturedReplyAccount,
|
|
965
|
+
child,
|
|
966
|
+
stdoutRl: null,
|
|
967
|
+
stderrRl: null,
|
|
968
|
+
pendingLines: [],
|
|
969
|
+
flushTimer: null,
|
|
970
|
+
inFlight: false,
|
|
971
|
+
inFlightWaiters: [],
|
|
972
|
+
lastDispatchAt: 0,
|
|
973
|
+
startedAt: Date.now(),
|
|
974
|
+
stopped: false,
|
|
975
|
+
explicitStop: false,
|
|
976
|
+
autoRestart: opts.autoRestart,
|
|
977
|
+
maxRestarts: opts.maxRestarts,
|
|
978
|
+
restartBackoffBaseMs: opts.restartBackoffBaseMs,
|
|
979
|
+
restartBackoffMaxMs: opts.restartBackoffMaxMs,
|
|
980
|
+
restartTimestamps: [],
|
|
981
|
+
restartTimer: null,
|
|
982
|
+
spawnOpts: opts,
|
|
983
|
+
stats: {
|
|
984
|
+
batches: 0,
|
|
985
|
+
lines: 0,
|
|
986
|
+
dropped: 0,
|
|
987
|
+
lastError: null,
|
|
988
|
+
restarts: 0
|
|
989
|
+
},
|
|
990
|
+
lastLineAt: Date.now(),
|
|
991
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
992
|
+
idleWatchdogTimer: null,
|
|
993
|
+
sessionNotFoundCount: 0,
|
|
994
|
+
sessionNotFoundMaxRetries: opts.sessionNotFoundMaxRetries,
|
|
995
|
+
maxBatchAgeMs: opts.maxBatchAgeMs,
|
|
996
|
+
batchStartedAt: 0,
|
|
997
|
+
lastLineTs: Date.now(),
|
|
998
|
+
flushStartTs: 0,
|
|
999
|
+
lastPreemptAt: 0,
|
|
1000
|
+
lastPreemptTrigger: null,
|
|
1001
|
+
flushAllPending: false,
|
|
1002
|
+
perfLogFile: null,
|
|
1003
|
+
gameId: ""
|
|
1004
|
+
};
|
|
1005
|
+
const perfDir = streamConfig?.streamPerfLogDir;
|
|
1006
|
+
if (perfDir) try {
|
|
1007
|
+
if (!existsSync(perfDir)) mkdirSync(perfDir, { recursive: true });
|
|
1008
|
+
inst.perfLogFile = `${perfDir}/perf-${opts.id}.log`;
|
|
1009
|
+
appendFileSync(inst.perfLogFile, `[SESSION_START id=${opts.id} ts=${Date.now()}]
|
|
1010
|
+
`);
|
|
1011
|
+
} catch {}
|
|
1012
|
+
instances.set(opts.id, inst);
|
|
1013
|
+
attachChildListeners(inst);
|
|
1014
|
+
armIdleWatchdog(inst);
|
|
1015
|
+
return inst;
|
|
1016
|
+
}
|
|
1017
|
+
async function doStart(ctx, params) {
|
|
1018
|
+
if (!resolvedOpenclawCli) return "Error: openclaw CLI not found. Set plugins.entries.clawclaw.config.streamOpenclawCli to its absolute path.";
|
|
1019
|
+
const command = String(params?.command ?? "").trim();
|
|
1020
|
+
if (!command) return "Error: command is required.";
|
|
1021
|
+
const id = params?.id ? String(params.id) : generateId();
|
|
1022
|
+
if (!ID_REGEX.test(id)) return "Error: id must match /^[A-Za-z0-9_-]{1,32}$/.";
|
|
1023
|
+
const max = cfg("maxInstances");
|
|
1024
|
+
if (instances.size >= max && !instances.has(id)) return `Error: max ${max} concurrent streams. Stop one before starting another.`;
|
|
1025
|
+
let filterRegex = null;
|
|
1026
|
+
if (params?.filter) try {
|
|
1027
|
+
filterRegex = new RegExp(String(params.filter));
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
return `Error: invalid filter regex: ${e?.message ?? e}`;
|
|
1030
|
+
}
|
|
1031
|
+
const args = Array.isArray(params?.args) ? params.args.map(String) : [];
|
|
1032
|
+
let useShell;
|
|
1033
|
+
if (params?.shell === void 0 || params?.shell === null) useShell = !/^(node|node\.exe)$/i.test(command);
|
|
1034
|
+
else if (typeof params.shell === "boolean" || typeof params.shell === "string") useShell = params.shell;
|
|
1035
|
+
else return "Error: shell must be boolean or string (path to shell binary).";
|
|
1036
|
+
const prefix = params?.prefix != null ? String(params.prefix) : `[stream:${id}] `;
|
|
1037
|
+
if (instances.has(id)) await stopInstance(id, {
|
|
1038
|
+
reason: "replaced",
|
|
1039
|
+
emitLifecycle: false
|
|
1040
|
+
});
|
|
1041
|
+
let inst;
|
|
1042
|
+
try {
|
|
1043
|
+
inst = startInstance({
|
|
1044
|
+
id,
|
|
1045
|
+
command,
|
|
1046
|
+
args,
|
|
1047
|
+
cwd: params?.cwd ? String(params.cwd) : void 0,
|
|
1048
|
+
env: params?.env && typeof params.env === "object" ? params.env : void 0,
|
|
1049
|
+
filterRegex,
|
|
1050
|
+
prefix,
|
|
1051
|
+
debounceMs: Number(params?.debounceMs ?? cfg("debounceMs")),
|
|
1052
|
+
minIntervalMs: Number(params?.minIntervalMs ?? cfg("minIntervalMs")),
|
|
1053
|
+
maxBatchLines: Number(params?.maxBatchLines ?? cfg("maxBatchLines")),
|
|
1054
|
+
maxQueueLines: Number(params?.maxQueueLines ?? cfg("maxQueueLines")),
|
|
1055
|
+
useShell,
|
|
1056
|
+
capturedSessionId: ctx.sessionId,
|
|
1057
|
+
capturedSessionKey: ctx.sessionKey,
|
|
1058
|
+
capturedChannel: ctx.deliveryContext?.channel ?? ctx.messageChannel,
|
|
1059
|
+
capturedReplyTo: ctx.deliveryContext?.to ?? ctx.currentChannelId,
|
|
1060
|
+
capturedReplyAccount: ctx.deliveryContext?.accountId ?? ctx.agentAccountId,
|
|
1061
|
+
autoRestart: params?.autoRestart != null ? Boolean(params.autoRestart) : cfg("autoRestart"),
|
|
1062
|
+
maxRestarts: Number(params?.maxRestarts ?? cfg("maxRestarts")),
|
|
1063
|
+
restartBackoffBaseMs: cfg("restartBackoffBaseMs"),
|
|
1064
|
+
restartBackoffMaxMs: cfg("restartBackoffMaxMs"),
|
|
1065
|
+
idleTimeoutMs: Number(cfg("idleTimeoutMs")),
|
|
1066
|
+
sessionNotFoundMaxRetries: Number(cfg("sessionNotFoundMaxRetries")),
|
|
1067
|
+
maxBatchAgeMs: Number(cfg("maxBatchAgeMs"))
|
|
1068
|
+
});
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
return `Error: failed to spawn '${command}': ${e?.message ?? e}`;
|
|
1071
|
+
}
|
|
1072
|
+
return [
|
|
1073
|
+
`Stream started.`,
|
|
1074
|
+
` id: ${inst.id}`,
|
|
1075
|
+
` pid: ${inst.child.pid ?? "?"}`,
|
|
1076
|
+
` command: ${command}${args.length ? " " + args.join(" ") : ""}`,
|
|
1077
|
+
` session: ${ctx.sessionKey ?? "(unknown key)"} / ${ctx.sessionId}`,
|
|
1078
|
+
` filter: ${filterRegex ? filterRegex.source : "(none)"}`,
|
|
1079
|
+
` debounce=${inst.debounceMs}ms minInterval=${inst.minIntervalMs}ms maxBatch=${inst.maxBatchLines} maxQueue=${inst.maxQueueLines}`,
|
|
1080
|
+
` autoRestart=${inst.autoRestart} maxRestarts=${inst.maxRestarts}`
|
|
1081
|
+
].join("\n");
|
|
1082
|
+
}
|
|
1083
|
+
function shortcutCclCommand() {
|
|
1084
|
+
if (!resolvedCcl) return null;
|
|
1085
|
+
return {
|
|
1086
|
+
command: resolvedCcl.exe,
|
|
1087
|
+
baseArgs: [...resolvedCcl.prefixArgs]
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
let globalHandlersRegistered = false;
|
|
1091
|
+
function registerGlobalCrashGuards() {
|
|
1092
|
+
if (globalHandlersRegistered) return;
|
|
1093
|
+
globalHandlersRegistered = true;
|
|
1094
|
+
process.on("unhandledRejection", (reason) => {
|
|
1095
|
+
streamLogger?.warn?.(`[clawclaw:stream] unhandledRejection swallowed: ${reason?.stack ?? reason?.message ?? String(reason)}`);
|
|
1096
|
+
});
|
|
1097
|
+
process.on("uncaughtException", (err) => {
|
|
1098
|
+
streamLogger?.warn?.(`[clawclaw:stream] uncaughtException swallowed: ${err?.stack ?? err?.message ?? String(err)}`);
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
function registerStreamTools(api, configSlice, cclInvoker) {
|
|
1102
|
+
streamLogger = api.logger;
|
|
1103
|
+
streamConfig = configSlice ?? {};
|
|
1104
|
+
resolvedCcl = cclInvoker;
|
|
1105
|
+
resolvedOpenclawCli = resolveOpenclawCli(configSlice?.streamOpenclawCli);
|
|
1106
|
+
configureSentry(configSlice?.streamSentryDsn, streamLogger);
|
|
1107
|
+
registerGlobalCrashGuards();
|
|
1108
|
+
if (!resolvedOpenclawCli) streamLogger?.warn?.("[clawclaw] openclaw CLI not found for stream subsystem; clawclaw_stream_* tools will fail. Set plugins.entries.clawclaw.config.streamOpenclawCli.");
|
|
1109
|
+
else streamLogger?.info?.(`[clawclaw:stream] using openclaw CLI: ${resolvedOpenclawCli.display}`);
|
|
1110
|
+
api.registerTool((ctx) => {
|
|
1111
|
+
if (!ctx?.sessionId) return null;
|
|
1112
|
+
return {
|
|
1113
|
+
name: "clawclaw_game_status",
|
|
1114
|
+
description: "List all running background game streams with their stats (pid, runtime, batches, lines, queue depth, last error).",
|
|
1115
|
+
parameters: {
|
|
1116
|
+
type: "object",
|
|
1117
|
+
additionalProperties: false,
|
|
1118
|
+
properties: {}
|
|
1119
|
+
},
|
|
1120
|
+
async execute(_callId) {
|
|
1121
|
+
if (instances.size === 0) return "No streams running.";
|
|
1122
|
+
const lines = [`Streams (${instances.size}):`];
|
|
1123
|
+
for (const inst of instances.values()) {
|
|
1124
|
+
const ageSec = ((Date.now() - inst.startedAt) / 1e3).toFixed(1);
|
|
1125
|
+
const cmd = inst.command + (inst.args.length ? " " + inst.args.join(" ") : "");
|
|
1126
|
+
const sameSession = inst.capturedSessionId === ctx.sessionId ? "" : " (other session)";
|
|
1127
|
+
lines.push(`- ${inst.id}${sameSession} | pid=${inst.child.pid ?? "?"} | session=${inst.capturedSessionKey ?? "?"} | ran=${ageSec}s | batches=${inst.stats.batches} | lines=${inst.stats.lines} | dropped=${inst.stats.dropped} | queue=${inst.pendingLines.length} | restarts=${inst.stats.restarts}/${inst.maxRestarts}${inst.autoRestart ? "" : " (autoRestart=off)"} | lastErr=${inst.stats.lastError ?? "-"} | cmd=${cmd}`);
|
|
1128
|
+
}
|
|
1129
|
+
return lines.join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
}, { name: "clawclaw_game_status" });
|
|
1133
|
+
api.registerTool((ctx) => {
|
|
1134
|
+
if (!ctx?.sessionId) return null;
|
|
1135
|
+
return {
|
|
1136
|
+
name: "clawclaw_game_stop",
|
|
1137
|
+
description: "Stop one (by id) or all background streams started via clawclaw_game_start. Use after `ccl game quit` (in-game) — the stream does not auto-exit when the daemon stops mid-game. ",
|
|
1138
|
+
parameters: {
|
|
1139
|
+
type: "object",
|
|
1140
|
+
additionalProperties: false,
|
|
1141
|
+
properties: { id: {
|
|
1142
|
+
type: "string",
|
|
1143
|
+
description: "Instance id; omit to stop all."
|
|
1144
|
+
} }
|
|
1145
|
+
},
|
|
1146
|
+
async execute(_callId, params) {
|
|
1147
|
+
if (instances.size === 0) return "No streams running.";
|
|
1148
|
+
if (params?.id) {
|
|
1149
|
+
const id = String(params.id);
|
|
1150
|
+
if (!instances.has(id)) return `No stream with id '${id}'.`;
|
|
1151
|
+
const inst = await stopInstance(id, { reason: "manual" });
|
|
1152
|
+
if (!inst) return `Failed to stop ${id}.`;
|
|
1153
|
+
return `Stopped ${id} (ran ${((Date.now() - inst.startedAt) / 1e3).toFixed(1)}s, ${inst.stats.batches} batches, ${inst.stats.lines} lines, ${inst.stats.dropped} dropped).`;
|
|
1154
|
+
}
|
|
1155
|
+
const ids = [...instances.keys()];
|
|
1156
|
+
for (const id of ids) try {
|
|
1157
|
+
await stopInstance(id, { reason: "manual" });
|
|
1158
|
+
} catch {}
|
|
1159
|
+
return `Stopped ${ids.length} stream(s): ${ids.join(", ")}.`;
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
}, { name: "clawclaw_game_stop" });
|
|
1163
|
+
api.registerTool((ctx) => {
|
|
1164
|
+
if (!ctx?.sessionId) return null;
|
|
1165
|
+
return {
|
|
1166
|
+
name: "clawclaw_game_start",
|
|
1167
|
+
description: "Start `ccl game start` as a background stream — attaches to an active game or joins the queue, waits for allocation, then pushes every matchmaking + gameplay event as new agent turns until game_over. One launch per match cycle. Also use for crash recovery (re-attach after a non-zero exit) — re-using the same id replaces the dead instance. Use clawclaw_game_stop({id}) to stop mid-cycle (after ccl game leave / quit).",
|
|
1168
|
+
parameters: {
|
|
1169
|
+
type: "object",
|
|
1170
|
+
additionalProperties: false,
|
|
1171
|
+
properties: { id: {
|
|
1172
|
+
type: "string",
|
|
1173
|
+
description: "Optional instance id; auto-generated if omitted. Re-using an id replaces a dead instance."
|
|
1174
|
+
} }
|
|
1175
|
+
},
|
|
1176
|
+
async execute(_callId, params) {
|
|
1177
|
+
const c = shortcutCclCommand();
|
|
1178
|
+
if (!c) return "Error: ccl invoker not resolved; cannot run shortcut.";
|
|
1179
|
+
return await doStart(ctx, {
|
|
1180
|
+
command: c.command,
|
|
1181
|
+
args: [
|
|
1182
|
+
...c.baseArgs,
|
|
1183
|
+
"game",
|
|
1184
|
+
"start"
|
|
1185
|
+
],
|
|
1186
|
+
id: params?.id,
|
|
1187
|
+
prefix: `[clawclaw:game] `,
|
|
1188
|
+
shell: false,
|
|
1189
|
+
env: { CLAWCLAW_STREAMED: "1" }
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
}, { name: "clawclaw_game_start" });
|
|
1194
|
+
if (typeof api.on === "function") api.on("gateway_stop", async () => {
|
|
1195
|
+
for (const id of [...instances.keys()]) try {
|
|
1196
|
+
await stopInstance(id, {
|
|
1197
|
+
reason: "gateway_stop",
|
|
1198
|
+
emitLifecycle: false
|
|
1199
|
+
});
|
|
1200
|
+
} catch {}
|
|
1201
|
+
await closeSentry();
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/index.ts
|
|
1206
|
+
/**
|
|
1207
|
+
* openclaw-clawclaw — OpenClaw plugin entry
|
|
1208
|
+
* ─────────────────────────────────────────
|
|
1209
|
+
* 工具组成:
|
|
1210
|
+
* - typed `clawclaw_*` 工具:大部分由 `ccl _schema` 元数据自动生成;
|
|
1211
|
+
* 少数(如 clawclaw_do)手写覆盖以补足 schema 表达不出的约束(如 oneOf)。
|
|
1212
|
+
* - clawclaw_run:raw passthrough,仅在 typed 工具未覆盖时使用,需 confirm:true。
|
|
1213
|
+
*
|
|
1214
|
+
* 对 clawclaw-cli 升级的兼容性:新增 `ccl <cmd>` 子命令时,只需重启 gateway,
|
|
1215
|
+
* 自动生成的工具会立刻暴露,无需重新发布插件。
|
|
1216
|
+
*/
|
|
1217
|
+
const PLUGIN_ID = "clawclaw";
|
|
1218
|
+
let pluginConfig = {};
|
|
1219
|
+
let pluginLogger = null;
|
|
1220
|
+
let resolvedCli = null;
|
|
1221
|
+
function readPluginConfigSlice(rootCfg) {
|
|
1222
|
+
const entry = rootCfg?.plugins?.entries?.[PLUGIN_ID];
|
|
1223
|
+
if (entry && typeof entry === "object" && entry.config && typeof entry.config === "object") return entry.config;
|
|
1224
|
+
return {};
|
|
1225
|
+
}
|
|
1226
|
+
function resolveCliInvoker(override) {
|
|
1227
|
+
const isWin = process.platform === "win32";
|
|
1228
|
+
const candidates = [];
|
|
1229
|
+
if (override) candidates.push(override);
|
|
1230
|
+
if (!override) {
|
|
1231
|
+
const binJs = resolvePackageBin(import.meta.url, "clawclaw-cli", join("bin", "clawclaw-cli.mjs"));
|
|
1232
|
+
if (binJs) return {
|
|
1233
|
+
exe: process.execPath,
|
|
1234
|
+
prefixArgs: [binJs],
|
|
1235
|
+
display: `${process.execPath} ${binJs}`
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
const exeNames = isWin ? [
|
|
1239
|
+
"ccl.cmd",
|
|
1240
|
+
"ccl.exe",
|
|
1241
|
+
"ccl",
|
|
1242
|
+
"clawclaw-cli.cmd",
|
|
1243
|
+
"clawclaw-cli.exe",
|
|
1244
|
+
"clawclaw-cli"
|
|
1245
|
+
] : ["ccl", "clawclaw-cli"];
|
|
1246
|
+
const pathDirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
1247
|
+
for (const dir of pathDirs) for (const exe of exeNames) candidates.push(`${dir}${sep}${exe}`);
|
|
1248
|
+
for (const c of candidates) {
|
|
1249
|
+
try {
|
|
1250
|
+
if (!existsSync(c) || !statSync(c).isFile()) continue;
|
|
1251
|
+
} catch {
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
const lower = c.toLowerCase();
|
|
1255
|
+
if (lower.endsWith(".exe")) return {
|
|
1256
|
+
exe: c,
|
|
1257
|
+
prefixArgs: [],
|
|
1258
|
+
display: c
|
|
1259
|
+
};
|
|
1260
|
+
if (lower.endsWith(".mjs") || lower.endsWith(".js")) return {
|
|
1261
|
+
exe: process.execPath,
|
|
1262
|
+
prefixArgs: [c],
|
|
1263
|
+
display: `${process.execPath} ${c}`
|
|
1264
|
+
};
|
|
1265
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
1266
|
+
const lastSep = c.lastIndexOf(sep);
|
|
1267
|
+
const dir = lastSep >= 0 ? c.substring(0, lastSep) : ".";
|
|
1268
|
+
const fname = c.substring(lastSep + 1).replace(/\.(cmd|bat)$/i, "");
|
|
1269
|
+
const heuristicMjs = join(dir, "node_modules", fname === "ccl" ? "clawclaw-cli" : fname, "bin", "clawclaw-cli.mjs");
|
|
1270
|
+
try {
|
|
1271
|
+
if (existsSync(heuristicMjs) && statSync(heuristicMjs).isFile()) return {
|
|
1272
|
+
exe: process.execPath,
|
|
1273
|
+
prefixArgs: [heuristicMjs],
|
|
1274
|
+
display: `${process.execPath} ${heuristicMjs}`
|
|
1275
|
+
};
|
|
1276
|
+
} catch {}
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if (!isWin) return {
|
|
1280
|
+
exe: c,
|
|
1281
|
+
prefixArgs: [],
|
|
1282
|
+
display: c
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
async function runCcl(args, opts = {}) {
|
|
1288
|
+
if (!resolvedCli) throw new Error("clawclaw-cli executable not found; set plugins.entries.clawclaw.config.cclPath");
|
|
1289
|
+
const { timeoutMs = pluginConfig.spawnTimeoutMs ?? 3e4 } = opts;
|
|
1290
|
+
const env = {
|
|
1291
|
+
...process.env,
|
|
1292
|
+
NO_COLOR: "1"
|
|
1293
|
+
};
|
|
1294
|
+
if (pluginConfig.workspaceDir) env.CLAWCLAW_WORKSPACE_DIR = pluginConfig.workspaceDir;
|
|
1295
|
+
return await new Promise((resolve, reject) => {
|
|
1296
|
+
const child = spawn(resolvedCli.exe, [...resolvedCli.prefixArgs, ...args], {
|
|
1297
|
+
env,
|
|
1298
|
+
windowsHide: true
|
|
1299
|
+
});
|
|
1300
|
+
let stdout = "";
|
|
1301
|
+
let stderr = "";
|
|
1302
|
+
const timer = setTimeout(() => {
|
|
1303
|
+
try {
|
|
1304
|
+
child.kill("SIGKILL");
|
|
1305
|
+
} catch {}
|
|
1306
|
+
reject(/* @__PURE__ */ new Error(`ccl ${args.join(" ")} timed out after ${timeoutMs}ms`));
|
|
1307
|
+
}, timeoutMs);
|
|
1308
|
+
child.stdout.on("data", (b) => {
|
|
1309
|
+
stdout += b.toString();
|
|
1310
|
+
});
|
|
1311
|
+
child.stderr.on("data", (b) => {
|
|
1312
|
+
stderr += b.toString();
|
|
1313
|
+
});
|
|
1314
|
+
child.on("error", (e) => {
|
|
1315
|
+
clearTimeout(timer);
|
|
1316
|
+
reject(e);
|
|
1317
|
+
});
|
|
1318
|
+
child.on("close", (code) => {
|
|
1319
|
+
clearTimeout(timer);
|
|
1320
|
+
if (code !== 0) {
|
|
1321
|
+
reject(/* @__PURE__ */ new Error(`ccl ${args.join(" ")} exited ${code}: ${stderr.trim() || stdout.trim()}`));
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const trimmed = stdout.trim();
|
|
1325
|
+
if (!trimmed) {
|
|
1326
|
+
resolve({
|
|
1327
|
+
ok: true,
|
|
1328
|
+
raw: ""
|
|
1329
|
+
});
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
resolve(JSON.parse(trimmed));
|
|
1334
|
+
} catch {
|
|
1335
|
+
resolve({ raw: trimmed });
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* NOTE: OpenClaw host invokes tool handlers as `execute(toolCallId, params)` (2 args).
|
|
1342
|
+
* This helper wraps the caller-provided 1-arg `execute(params)` to match the host
|
|
1343
|
+
* 2-arg convention internally, so call sites stay clean.
|
|
1344
|
+
*/
|
|
1345
|
+
function tool(name, description, parameters, execute) {
|
|
1346
|
+
return (ctx) => {
|
|
1347
|
+
if (!ctx?.sessionId) return null;
|
|
1348
|
+
return {
|
|
1349
|
+
name,
|
|
1350
|
+
description,
|
|
1351
|
+
parameters,
|
|
1352
|
+
async execute(_callId, params) {
|
|
1353
|
+
return await execute(params);
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
/** Run `ccl _schema` synchronously at register() time. Returns null on failure. */
|
|
1359
|
+
function fetchSchemaSync(invoker) {
|
|
1360
|
+
try {
|
|
1361
|
+
const r = spawnSync(invoker.exe, [...invoker.prefixArgs, "_schema"], {
|
|
1362
|
+
env: {
|
|
1363
|
+
...process.env,
|
|
1364
|
+
NO_COLOR: "1"
|
|
1365
|
+
},
|
|
1366
|
+
windowsHide: true,
|
|
1367
|
+
timeout: 15e3,
|
|
1368
|
+
encoding: "utf-8"
|
|
1369
|
+
});
|
|
1370
|
+
if (r.status !== 0) {
|
|
1371
|
+
pluginLogger?.warn?.(`[clawclaw] schema fetch failed (exit ${r.status}); auto-generation skipped. stderr: ${(r.stderr ?? "").slice(0, 400)}`);
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
return JSON.parse(r.stdout);
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
pluginLogger?.warn?.(`[clawclaw] schema fetch threw: ${e?.message ?? e}; auto-generation skipped`);
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
function inferOptionType(opt) {
|
|
1381
|
+
if (!opt.takesValue) return { type: "boolean" };
|
|
1382
|
+
if (typeof opt.defaultValue === "number") return {
|
|
1383
|
+
type: "integer",
|
|
1384
|
+
default: opt.defaultValue
|
|
1385
|
+
};
|
|
1386
|
+
if (typeof opt.defaultValue === "string" && /^-?\d+$/.test(opt.defaultValue)) return {
|
|
1387
|
+
type: "integer",
|
|
1388
|
+
default: Number(opt.defaultValue)
|
|
1389
|
+
};
|
|
1390
|
+
if (opt.flags.includes("<number>") || /\bnumber\b/i.test(opt.description ?? "")) return opt.defaultValue !== void 0 ? {
|
|
1391
|
+
type: "integer",
|
|
1392
|
+
default: opt.defaultValue
|
|
1393
|
+
} : { type: "integer" };
|
|
1394
|
+
return opt.defaultValue !== void 0 ? {
|
|
1395
|
+
type: "string",
|
|
1396
|
+
default: opt.defaultValue
|
|
1397
|
+
} : { type: "string" };
|
|
1398
|
+
}
|
|
1399
|
+
function inferArgumentType(arg) {
|
|
1400
|
+
if (arg.variadic) return {
|
|
1401
|
+
type: "array",
|
|
1402
|
+
items: { type: "string" }
|
|
1403
|
+
};
|
|
1404
|
+
return { type: "string" };
|
|
1405
|
+
}
|
|
1406
|
+
function buildParametersSchema(opts, args, meta) {
|
|
1407
|
+
const properties = {};
|
|
1408
|
+
const required = [];
|
|
1409
|
+
for (const o of opts) {
|
|
1410
|
+
if (!o.long) continue;
|
|
1411
|
+
const inferred = inferOptionType(o);
|
|
1412
|
+
properties[o.attributeName] = {
|
|
1413
|
+
...inferred,
|
|
1414
|
+
...o.description ? { description: o.description } : {}
|
|
1415
|
+
};
|
|
1416
|
+
if (o.mandatory) required.push(o.attributeName);
|
|
1417
|
+
}
|
|
1418
|
+
for (const a of args) {
|
|
1419
|
+
properties[a.name] = {
|
|
1420
|
+
...inferArgumentType(a),
|
|
1421
|
+
...a.description ? { description: a.description } : {}
|
|
1422
|
+
};
|
|
1423
|
+
if (a.required && !a.variadic) required.push(a.name);
|
|
1424
|
+
}
|
|
1425
|
+
if (meta?.requiresConfirm || meta?.sensitive) {
|
|
1426
|
+
properties.confirm = {
|
|
1427
|
+
type: "boolean",
|
|
1428
|
+
description: "MUST be true to proceed. This command is marked " + (meta?.sensitive ? "SENSITIVE (writes credentials / consumes quota / similar). " : "as requiring explicit confirmation. ") + "Setting this acknowledges the consequence."
|
|
1429
|
+
};
|
|
1430
|
+
required.push("confirm");
|
|
1431
|
+
}
|
|
1432
|
+
const schema = {
|
|
1433
|
+
type: "object",
|
|
1434
|
+
additionalProperties: false,
|
|
1435
|
+
properties
|
|
1436
|
+
};
|
|
1437
|
+
if (required.length) schema.required = required;
|
|
1438
|
+
return schema;
|
|
1439
|
+
}
|
|
1440
|
+
function buildExecutor(commandPath, opts, args, meta) {
|
|
1441
|
+
const cmdParts = commandPath.split(/\s+/).filter(Boolean);
|
|
1442
|
+
const requireConfirm = meta?.requiresConfirm || meta?.sensitive;
|
|
1443
|
+
return async (params) => {
|
|
1444
|
+
if (requireConfirm && params?.confirm !== true) throw new Error(`ccl ${commandPath} requires { confirm: true } (marked as ${meta?.sensitive ? "sensitive" : "requires-confirm"})`);
|
|
1445
|
+
const cliArgs = [...cmdParts];
|
|
1446
|
+
for (const o of opts) {
|
|
1447
|
+
if (!o.long) continue;
|
|
1448
|
+
if (o.attributeName === "confirm") continue;
|
|
1449
|
+
const v = params?.[o.attributeName];
|
|
1450
|
+
if (v === void 0 || v === null) continue;
|
|
1451
|
+
if (!o.takesValue) {
|
|
1452
|
+
if (v === true) cliArgs.push(o.long);
|
|
1453
|
+
} else cliArgs.push(o.long, String(v));
|
|
1454
|
+
}
|
|
1455
|
+
for (const a of args) {
|
|
1456
|
+
const v = params?.[a.name];
|
|
1457
|
+
if (v === void 0 || v === null) continue;
|
|
1458
|
+
if (a.variadic && Array.isArray(v)) cliArgs.push(...v.map(String));
|
|
1459
|
+
else cliArgs.push(String(v));
|
|
1460
|
+
}
|
|
1461
|
+
return await runCcl(cliArgs, { timeoutMs: meta?.timeoutMs });
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
/** Hand-written overrides — auto-gen will skip these so the curated version wins. */
|
|
1465
|
+
const OVERRIDDEN_TOOL_NAMES = new Set([
|
|
1466
|
+
"clawclaw_state",
|
|
1467
|
+
"clawclaw_do",
|
|
1468
|
+
"clawclaw_events",
|
|
1469
|
+
"clawclaw_account_info",
|
|
1470
|
+
"clawclaw_game_start",
|
|
1471
|
+
"clawclaw_game_stop"
|
|
1472
|
+
]);
|
|
1473
|
+
/** Skip these command paths from auto-gen entirely (e.g., upgrade is unsafe for LLM). */
|
|
1474
|
+
const SKIP_PATHS = new Set([
|
|
1475
|
+
"upgrade",
|
|
1476
|
+
"game join",
|
|
1477
|
+
"game queue"
|
|
1478
|
+
]);
|
|
1479
|
+
/** Track which tool names came from auto-gen, for diagnostic logging. */
|
|
1480
|
+
function emitAutoTools(schema, api) {
|
|
1481
|
+
const names = [];
|
|
1482
|
+
const visit = (node) => {
|
|
1483
|
+
if (node.leaf && node.path) {
|
|
1484
|
+
const toolName = `clawclaw_${node.path.replace(/\s+/g, "_")}`;
|
|
1485
|
+
if (OVERRIDDEN_TOOL_NAMES.has(toolName)) return;
|
|
1486
|
+
if (SKIP_PATHS.has(node.path)) return;
|
|
1487
|
+
const meta = node.meta;
|
|
1488
|
+
const params = buildParametersSchema(node.options, node.arguments, meta);
|
|
1489
|
+
const exec = buildExecutor(node.path, node.options, node.arguments, meta);
|
|
1490
|
+
const desc = [[
|
|
1491
|
+
meta?.sensitive && "⚠️ SENSITIVE",
|
|
1492
|
+
meta?.requiresConfirm && "⚠️ REQUIRES confirm:true",
|
|
1493
|
+
meta?.longRunning && `⚠️ LONG-RUNNING (timeout ${(meta.timeoutMs ?? 0) / 1e3}s)`
|
|
1494
|
+
].filter(Boolean).join(" · ") || null, node.description || `Run \`ccl ${node.path}\``].filter(Boolean).join("\n");
|
|
1495
|
+
api.registerTool(tool(toolName, desc, params, exec), { name: toolName });
|
|
1496
|
+
names.push(toolName);
|
|
1497
|
+
}
|
|
1498
|
+
for (const sub of node.subcommands) visit(sub);
|
|
1499
|
+
};
|
|
1500
|
+
visit(schema);
|
|
1501
|
+
return {
|
|
1502
|
+
count: names.length,
|
|
1503
|
+
names
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
const rawToolFactory = tool("clawclaw_run", "RAW PASSTHROUGH — only use when no typed clawclaw_* tool covers the action. Spawns `ccl <command> <...args>` directly with no parameter validation. Set `confirm: true` to acknowledge using this bypass. Prefer typed tools for safety, schema, and discoverability.", {
|
|
1507
|
+
type: "object",
|
|
1508
|
+
additionalProperties: false,
|
|
1509
|
+
required: ["command", "confirm"],
|
|
1510
|
+
properties: {
|
|
1511
|
+
command: {
|
|
1512
|
+
type: "string",
|
|
1513
|
+
description: "CCL command path (may contain spaces for subcommands), e.g. \"state\", \"account leaderboard\", \"watch\". Will be split on whitespace."
|
|
1514
|
+
},
|
|
1515
|
+
args: {
|
|
1516
|
+
type: "array",
|
|
1517
|
+
items: { type: "string" },
|
|
1518
|
+
description: "Additional CLI arguments after the command path.",
|
|
1519
|
+
default: []
|
|
1520
|
+
},
|
|
1521
|
+
confirm: {
|
|
1522
|
+
type: "boolean",
|
|
1523
|
+
description: "MUST be true. Acknowledges that the typed clawclaw_* tools were considered first and do not cover the requested action."
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}, async (args) => {
|
|
1527
|
+
if (args?.confirm !== true) throw new Error("clawclaw_run requires { confirm: true } to acknowledge typed clawclaw_* tools were considered first");
|
|
1528
|
+
const cmdParts = (args.command ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1529
|
+
if (!cmdParts.length) throw new Error("clawclaw_run: command is required");
|
|
1530
|
+
return await runCcl([...cmdParts, ...args.args ?? []]);
|
|
1531
|
+
});
|
|
1532
|
+
var src_default = definePluginEntry({
|
|
1533
|
+
id: PLUGIN_ID,
|
|
1534
|
+
name: "ClawClaw (龙虾杀)",
|
|
1535
|
+
description: "Play ClawClaw (龙虾杀) social deduction game via clawclaw-cli.",
|
|
1536
|
+
register(api) {
|
|
1537
|
+
pluginLogger = api.logger;
|
|
1538
|
+
pluginConfig = readPluginConfigSlice(api.config);
|
|
1539
|
+
resolvedCli = resolveCliInvoker(pluginConfig.cclPath);
|
|
1540
|
+
if (!resolvedCli) pluginLogger?.warn?.("[clawclaw] clawclaw-cli (ccl) not found; tools will fail at execute time. Install via `npm i -g clawclaw-cli` or set plugins.entries.clawclaw.config.cclPath.");
|
|
1541
|
+
else pluginLogger?.info?.(`[clawclaw] using ccl: ${resolvedCli.display}`);
|
|
1542
|
+
api.registerTool(tool("clawclaw_state", "Read the current ClawClaw game state (phase / position / players / corpses / tasks / meeting / events). Read-only; safe to call any time. Returns parsed JSON from `ccl state`.", {
|
|
1543
|
+
type: "object",
|
|
1544
|
+
additionalProperties: false,
|
|
1545
|
+
properties: { full: {
|
|
1546
|
+
type: "boolean",
|
|
1547
|
+
description: "Return full state instead of brief summary. Default false."
|
|
1548
|
+
} }
|
|
1549
|
+
}, async (args) => {
|
|
1550
|
+
const cliArgs = ["state"];
|
|
1551
|
+
if (args?.full) cliArgs.push("--full");
|
|
1552
|
+
return await runCcl(cliArgs);
|
|
1553
|
+
}), { name: "clawclaw_state" });
|
|
1554
|
+
api.registerTool(tool("clawclaw_do", "Submit a single player communication action: SPEECH, VOTE, or THINK. Provide exactly one of `speech` / `vote` / `think`. Optionally attach `audioUrl` to a speech for TTS.", {
|
|
1555
|
+
type: "object",
|
|
1556
|
+
additionalProperties: false,
|
|
1557
|
+
properties: {
|
|
1558
|
+
speech: {
|
|
1559
|
+
type: "string",
|
|
1560
|
+
description: "Text to broadcast to nearby players or as a meeting speech."
|
|
1561
|
+
},
|
|
1562
|
+
vote: {
|
|
1563
|
+
type: "string",
|
|
1564
|
+
description: "Player name to vote out (meeting vote sub-phase only)."
|
|
1565
|
+
},
|
|
1566
|
+
think: {
|
|
1567
|
+
type: "string",
|
|
1568
|
+
description: "Spectator-visible reasoning. Standalone = no-op think action."
|
|
1569
|
+
},
|
|
1570
|
+
audioUrl: {
|
|
1571
|
+
type: "string",
|
|
1572
|
+
description: "Optional TTS audio URL. Only meaningful with `speech`."
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}, async (args) => {
|
|
1576
|
+
const a = args ?? {};
|
|
1577
|
+
const primary = [
|
|
1578
|
+
"speech",
|
|
1579
|
+
"vote",
|
|
1580
|
+
"think"
|
|
1581
|
+
].filter((k) => typeof a[k] === "string" && a[k].length > 0);
|
|
1582
|
+
if (primary.length === 0) throw new Error("clawclaw_do: must provide one of speech / vote / think");
|
|
1583
|
+
if (primary.length > 1) throw new Error(`clawclaw_do: provide exactly one primary action (got: ${primary.join(", ")})`);
|
|
1584
|
+
const cliArgs = ["do"];
|
|
1585
|
+
if (a.speech) cliArgs.push("--speech", a.speech);
|
|
1586
|
+
if (a.vote) cliArgs.push("--vote", a.vote);
|
|
1587
|
+
if (a.think) cliArgs.push("--think", a.think);
|
|
1588
|
+
if (a.audioUrl && a.speech) cliArgs.push("--url", a.audioUrl);
|
|
1589
|
+
return await runCcl(cliArgs);
|
|
1590
|
+
}), { name: "clawclaw_do" });
|
|
1591
|
+
api.registerTool(tool("clawclaw_events", "Fetch recent game events. Read-only. Filter by `type` and limit count via `last`. For destructive --clear, use the `clawclaw_run` tool with `{ command: \"events --clear\", confirm: true }`.", {
|
|
1592
|
+
type: "object",
|
|
1593
|
+
additionalProperties: false,
|
|
1594
|
+
properties: {
|
|
1595
|
+
last: {
|
|
1596
|
+
type: "integer",
|
|
1597
|
+
minimum: 1,
|
|
1598
|
+
maximum: 500,
|
|
1599
|
+
default: 10,
|
|
1600
|
+
description: "Number of most-recent events."
|
|
1601
|
+
},
|
|
1602
|
+
type: {
|
|
1603
|
+
type: "string",
|
|
1604
|
+
description: "Filter by event type (e.g. \"kill\", \"speech\", \"vote\")."
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}, async (args) => {
|
|
1608
|
+
const cliArgs = [
|
|
1609
|
+
"events",
|
|
1610
|
+
"-n",
|
|
1611
|
+
String(args?.last ?? 10)
|
|
1612
|
+
];
|
|
1613
|
+
if (args?.type) cliArgs.push("--type", args.type);
|
|
1614
|
+
return await runCcl(cliArgs);
|
|
1615
|
+
}), { name: "clawclaw_events" });
|
|
1616
|
+
api.registerTool(tool("clawclaw_account_info", "Return the currently-active ClawClaw account: agent name, Claw Key, persona, and spectator URL. Read-only.", {
|
|
1617
|
+
type: "object",
|
|
1618
|
+
additionalProperties: false,
|
|
1619
|
+
properties: {}
|
|
1620
|
+
}, async () => await runCcl(["account", "info"])), { name: "clawclaw_account_info" });
|
|
1621
|
+
let autoStats = {
|
|
1622
|
+
count: 0,
|
|
1623
|
+
names: []
|
|
1624
|
+
};
|
|
1625
|
+
if (resolvedCli) {
|
|
1626
|
+
const schema = fetchSchemaSync(resolvedCli);
|
|
1627
|
+
if (schema) {
|
|
1628
|
+
autoStats = emitAutoTools(schema, api);
|
|
1629
|
+
pluginLogger?.info?.(`[clawclaw] auto-generated ${autoStats.count} tool(s) from schema: ${autoStats.names.join(", ")}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
api.registerTool(rawToolFactory, { name: "clawclaw_run" });
|
|
1633
|
+
registerStreamTools(api, pluginConfig, resolvedCli);
|
|
1634
|
+
const handWrittenInIndex = 4;
|
|
1635
|
+
const streamCount = 3;
|
|
1636
|
+
pluginLogger?.info?.(`[clawclaw] tools registered: ${handWrittenInIndex} hand-written + ${autoStats.count} auto + 1 raw + ${streamCount} stream = ${handWrittenInIndex + autoStats.count + 1 + streamCount} total`);
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
//#endregion
|
|
1640
|
+
export { src_default as default };
|