@clawling/clawchat-plugin-openclaw 2026.9.26-3 → 2026.10.7-2
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/dist/src/config.js +3 -0
- package/dist/src/framework-error-copy.js +172 -0
- package/dist/src/liveware-sample.js +106 -19
- package/dist/src/mention-autolink.js +155 -0
- package/dist/src/no-reply.js +7 -6
- package/dist/src/outbound.js +28 -7
- package/dist/src/permission-result.js +116 -27
- package/dist/src/profile-prompt.js +17 -2
- package/dist/src/reply-dispatcher.js +145 -31
- package/dist/src/reply-stream.js +208 -0
- package/dist/src/runtime.js +135 -2
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +1 -1
- package/dist/src/tools.js +105 -12
- package/openclaw.plugin.json +4 -0
- package/package.json +3 -8
- package/skills/clawchat-core/SKILL.md +12 -3
- package/skills/clawchat-liveware-sample/SKILL.md +3 -2
- package/skills/clawchat-orchestration/SKILL.md +30 -1
- package/skills/clawchat-set-greeting/SKILL.md +5 -4
- package/skills/manifest.json +31 -31
- package/src/config.ts +7 -0
- package/src/framework-error-copy.ts +185 -0
- package/src/liveware-sample.ts +106 -19
- package/src/mention-autolink.ts +200 -0
- package/src/no-reply.ts +7 -6
- package/src/outbound.ts +58 -7
- package/src/permission-result.ts +134 -30
- package/src/profile-prompt.ts +21 -2
- package/src/reply-dispatcher.ts +163 -28
- package/src/reply-stream.ts +244 -0
- package/src/runtime.ts +136 -3
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +1 -1
- package/src/tools.ts +112 -10
package/dist/src/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
82
82
|
forwardThinking: { type: "boolean" },
|
|
83
83
|
forwardToolCalls: { type: "boolean" },
|
|
84
84
|
richInteractions: { type: "boolean" },
|
|
85
|
+
streamReplies: { type: "boolean" },
|
|
85
86
|
awarenessNote: { type: "boolean" },
|
|
86
87
|
friendGreeting: { type: "boolean" },
|
|
87
88
|
livewareSample: { type: "boolean" },
|
|
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
448
449
|
const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
449
450
|
const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
450
451
|
const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
452
|
+
const streamReplies = typeof channel.streamReplies === "boolean" ? channel.streamReplies : false;
|
|
451
453
|
const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
452
454
|
const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
|
|
453
455
|
const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
|
|
@@ -476,6 +478,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
476
478
|
forwardThinking,
|
|
477
479
|
forwardToolCalls,
|
|
478
480
|
richInteractions,
|
|
481
|
+
streamReplies,
|
|
479
482
|
awarenessNote,
|
|
480
483
|
friendGreeting,
|
|
481
484
|
livewareSample,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-language copy for OpenClaw framework errors that reach a ClawChat
|
|
3
|
+
* conversation as a final reply.
|
|
4
|
+
*
|
|
5
|
+
* The host hands the channel its own English failure text (rate limit, expired
|
|
6
|
+
* provider login, context overflow, billing, timeouts — see
|
|
7
|
+
* `docs/clawchat-plugin-openclaw.md` "Framework error replies"). Posting that
|
|
8
|
+
* raw leaves a non-English owner with a message they cannot act on. Instead we
|
|
9
|
+
* lead with one sentence in the owner's language saying what happened and what
|
|
10
|
+
* to do, then keep the original text at the end so it can still be reported.
|
|
11
|
+
*/
|
|
12
|
+
import { resolveOwnerLanguage } from "./owner-language.js";
|
|
13
|
+
const WARNING_PREFIX = "⚠️";
|
|
14
|
+
/**
|
|
15
|
+
* Host lifecycle notices that look like errors but are not. The host itself
|
|
16
|
+
* emits "Gateway restarting…" without `isError`; it is listed here too so a
|
|
17
|
+
* future `⚠️`/`isError` variant still passes through untouched.
|
|
18
|
+
*/
|
|
19
|
+
const PASS_THROUGH = [/^(?:⚠️\s*)?gateway restarting\b/i];
|
|
20
|
+
/**
|
|
21
|
+
* Ordered: the first entry whose patterns match wins. Context overflow sits
|
|
22
|
+
* before timeout because the host's compaction failure reads "Context is too
|
|
23
|
+
* large and auto-compaction timed out"; the weak billing words (quota, credit,
|
|
24
|
+
* billing) sit after rate limit because the host's bare-429 copy names them as
|
|
25
|
+
* possible causes of what is reported as a 429.
|
|
26
|
+
*/
|
|
27
|
+
const CATEGORY_PATTERNS = [
|
|
28
|
+
[
|
|
29
|
+
"context_overflow",
|
|
30
|
+
[
|
|
31
|
+
/context[\s_-]*(?:length|window|overflow|limit)/i,
|
|
32
|
+
/maximum context/i,
|
|
33
|
+
/context[\s_-]*length[\s_-]*exceeded/i,
|
|
34
|
+
/\bcontext is too (?:large|long)\b/i,
|
|
35
|
+
/\b(?:prompt|input|conversation|request|message)s? (?:is |was )?too (?:long|large)\b/i,
|
|
36
|
+
/\btoo many (?:input )?tokens\b/i,
|
|
37
|
+
],
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
// Unambiguous billing wording first: provider quota errors often ride an
|
|
41
|
+
// HTTP 429 ("429 You exceeded your current quota"), which would otherwise
|
|
42
|
+
// read as a rate limit.
|
|
43
|
+
"billing",
|
|
44
|
+
[
|
|
45
|
+
/insufficient[\s_-]*(?:quota|balance|credits?|funds)/i,
|
|
46
|
+
/exceeded your current quota/i,
|
|
47
|
+
/out of credits?\b/i,
|
|
48
|
+
/billing error/i,
|
|
49
|
+
/\b402\b/,
|
|
50
|
+
/payment required/i,
|
|
51
|
+
],
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
"rate_limit",
|
|
55
|
+
[
|
|
56
|
+
/rate[\s_-]*limit/i,
|
|
57
|
+
/\b429\b/,
|
|
58
|
+
/overloaded/i,
|
|
59
|
+
/too many requests/i,
|
|
60
|
+
/needs a short break/i,
|
|
61
|
+
/\bat capacity\b/i,
|
|
62
|
+
/asking us to slow down/i,
|
|
63
|
+
/resource[\s_-]*exhausted/i,
|
|
64
|
+
],
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"auth",
|
|
68
|
+
[
|
|
69
|
+
/\b401\b/,
|
|
70
|
+
/unauthori[sz]ed/i,
|
|
71
|
+
/authenticat(?:e|ion)/i,
|
|
72
|
+
/\bre-?auth\b/i,
|
|
73
|
+
/(?:invalid|incorrect|missing|wrong|expired) (?:x-)?api[\s_-]*key/i,
|
|
74
|
+
/api[\s_-]*key (?:is |was )?(?:invalid|incorrect|missing|expired|not valid)/i,
|
|
75
|
+
/log(?:in|-in)? (?:has )?(?:expired|failed)/i,
|
|
76
|
+
/\bsaved logins?\b/i,
|
|
77
|
+
/couldn't sign in|could not sign in|sign[\s-]*in (?:has )?expired/i,
|
|
78
|
+
/\bauth profile\b/i,
|
|
79
|
+
/(?:oauth|access|provider) token (?:has |may have )?expired/i,
|
|
80
|
+
],
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"billing",
|
|
84
|
+
[/billing/i, /\bquota\b/i, /\bcredits?\b/i, /\bbalance\b/i],
|
|
85
|
+
],
|
|
86
|
+
["timeout", [/timed?[\s_-]*out\b/i, /\btimeout\b/i, /\bwatchdog\b/i]],
|
|
87
|
+
];
|
|
88
|
+
export function classifyFrameworkError(text) {
|
|
89
|
+
for (const [category, patterns] of CATEGORY_PATTERNS) {
|
|
90
|
+
if (patterns.some((p) => p.test(text)))
|
|
91
|
+
return category;
|
|
92
|
+
}
|
|
93
|
+
return "unknown";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether a final reply is a framework error to be rewritten. `isError` is the
|
|
97
|
+
* authority; a leading "⚠️" is only a fallback for host copy that does not set
|
|
98
|
+
* it. Host lifecycle notices pass through.
|
|
99
|
+
*/
|
|
100
|
+
export function isFrameworkErrorReply(payload, text) {
|
|
101
|
+
const trimmed = text.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
return false;
|
|
104
|
+
if (PASS_THROUGH.some((p) => p.test(trimmed)))
|
|
105
|
+
return false;
|
|
106
|
+
return payload.isError === true || trimmed.startsWith(WARNING_PREFIX);
|
|
107
|
+
}
|
|
108
|
+
const COPY = {
|
|
109
|
+
rate_limit: {
|
|
110
|
+
zh: "模型服务这会儿太忙,暂时不接新请求。过几分钟再发一次。",
|
|
111
|
+
zh_Hant: "模型服務這會兒太忙,暫時不接新請求。過幾分鐘再傳一次。",
|
|
112
|
+
en: "The model service is busy right now and isn't taking new requests. Try again in a few minutes.",
|
|
113
|
+
ja: "モデルサービスが混み合っていて、いまは新しいリクエストを受け付けていません。数分後にもう一度送ってください。",
|
|
114
|
+
es: "El servicio del modelo está saturado y ahora no acepta nuevas solicitudes. Vuelve a enviarlo en unos minutos.",
|
|
115
|
+
ko: "모델 서비스가 지금 너무 바빠서 새 요청을 받지 않고 있어요. 몇 분 뒤에 다시 보내 주세요.",
|
|
116
|
+
},
|
|
117
|
+
auth: {
|
|
118
|
+
zh: "模型服务的登录失效了,或者 API Key 不对。请在运行 OpenClaw 的那台机器上重新登录模型服务(或更新 API Key),再发一次。",
|
|
119
|
+
zh_Hant: "模型服務的登入失效了,或者 API Key 不對。請在執行 OpenClaw 的那台機器上重新登入模型服務(或更新 API Key),再傳一次。",
|
|
120
|
+
en: "The model service sign-in has expired, or the API key is wrong. On the machine running OpenClaw, sign in to the model service again (or update the API key), then try again.",
|
|
121
|
+
ja: "モデルサービスのログインが切れているか、API キーが正しくありません。OpenClaw を動かしているマシンでモデルサービスにログインし直して(または API キーを更新して)から、もう一度送ってください。",
|
|
122
|
+
es: "La sesión del servicio del modelo ha caducado o la clave de API no es correcta. En el equipo donde se ejecuta OpenClaw, vuelve a iniciar sesión en el servicio del modelo (o actualiza la clave de API) y envíalo de nuevo.",
|
|
123
|
+
ko: "모델 서비스 로그인이 만료됐거나 API 키가 올바르지 않아요. OpenClaw를 실행 중인 기기에서 모델 서비스에 다시 로그인(또는 API 키를 업데이트)한 뒤 다시 보내 주세요.",
|
|
124
|
+
},
|
|
125
|
+
context_overflow: {
|
|
126
|
+
zh: "这段对话太长,模型装不下了。发送 /new 开始新对话,再接着说。",
|
|
127
|
+
zh_Hant: "這段對話太長,模型裝不下了。傳送 /new 開始新對話,再接著說。",
|
|
128
|
+
en: "This conversation is too long for the model. Send /new to start a new conversation, then carry on.",
|
|
129
|
+
ja: "この会話が長すぎて、モデルが処理しきれません。/new を送って新しい会話を始めてから続けてください。",
|
|
130
|
+
es: "Esta conversación es demasiado larga para el modelo. Envía /new para empezar una conversación nueva y sigue desde ahí.",
|
|
131
|
+
ko: "이 대화가 너무 길어서 모델이 처리할 수 없어요. /new를 보내 새 대화를 시작한 뒤 이어서 말해 주세요.",
|
|
132
|
+
},
|
|
133
|
+
billing: {
|
|
134
|
+
zh: "模型服务的账户余额不足。请到模型服务商那里充值或查看账单,再发一次。",
|
|
135
|
+
zh_Hant: "模型服務的帳戶餘額不足。請到模型服務商那裡儲值或查看帳單,再傳一次。",
|
|
136
|
+
en: "The model service account is out of credit. Top up or check billing with your model provider, then try again.",
|
|
137
|
+
ja: "モデルサービスのアカウント残高が不足しています。モデルの提供元でチャージするか請求状況を確認してから、もう一度送ってください。",
|
|
138
|
+
es: "La cuenta del servicio del modelo no tiene saldo suficiente. Recarga o revisa la facturación con tu proveedor del modelo y vuelve a enviarlo.",
|
|
139
|
+
ko: "모델 서비스 계정 잔액이 부족해요. 모델 제공업체에서 충전하거나 결제 내역을 확인한 뒤 다시 보내 주세요.",
|
|
140
|
+
},
|
|
141
|
+
timeout: {
|
|
142
|
+
zh: "这次处理超时,没有做完。再发一次试试;总是超时的话,把任务拆小一点。",
|
|
143
|
+
zh_Hant: "這次處理逾時,沒有做完。再傳一次試試;總是逾時的話,把任務拆小一點。",
|
|
144
|
+
en: "This took too long and didn't finish. Try again; if it keeps timing out, break the task into smaller steps.",
|
|
145
|
+
ja: "処理がタイムアウトして、最後まで終わりませんでした。もう一度送ってみてください。何度もタイムアウトする場合は、タスクを小さく分けてください。",
|
|
146
|
+
es: "Esto tardó demasiado y no terminó. Vuelve a intentarlo; si sigue pasando, divide la tarea en partes más pequeñas.",
|
|
147
|
+
ko: "처리 시간이 초과되어 끝까지 마치지 못했어요. 다시 보내 보세요. 계속 시간이 초과되면 작업을 더 작게 나눠 주세요.",
|
|
148
|
+
},
|
|
149
|
+
unknown: {
|
|
150
|
+
zh: "这次出错了,没有做完。再发一次试试;还不行的话,可以把下面的原文发给 ClawChat 客服。",
|
|
151
|
+
zh_Hant: "這次出錯了,沒有做完。再傳一次試試;還不行的話,可以把下面的原文傳給 ClawChat 客服。",
|
|
152
|
+
en: "Something went wrong and this didn't finish. Try again; if it still fails, send the original message below to ClawChat support.",
|
|
153
|
+
ja: "エラーが起きて、最後まで終わりませんでした。もう一度送ってみてください。それでもだめなら、下の原文を ClawChat サポートに送ってください。",
|
|
154
|
+
es: "Algo salió mal y esto no terminó. Vuelve a intentarlo; si sigue fallando, envía el texto original de abajo al soporte de ClawChat.",
|
|
155
|
+
ko: "오류가 생겨 끝까지 마치지 못했어요. 다시 보내 보세요. 그래도 안 되면 아래 원문을 ClawChat 고객지원에 보내 주세요.",
|
|
156
|
+
},
|
|
157
|
+
original_label: {
|
|
158
|
+
zh: "原文:",
|
|
159
|
+
zh_Hant: "原文:",
|
|
160
|
+
en: "Original:",
|
|
161
|
+
ja: "原文:",
|
|
162
|
+
es: "Texto original:",
|
|
163
|
+
ko: "원문:",
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
/** `<owner-language sentence>\n\n<label> <original text>`. */
|
|
167
|
+
export function formatFrameworkErrorForOwner(text, locale) {
|
|
168
|
+
const language = resolveOwnerLanguage(locale);
|
|
169
|
+
const original = text.trim();
|
|
170
|
+
const category = classifyFrameworkError(original);
|
|
171
|
+
return `${COPY[category][language]}\n\n${COPY.original_label[language]} ${original}`;
|
|
172
|
+
}
|
|
@@ -209,10 +209,20 @@ export async function downloadLivewareSample(opts) {
|
|
|
209
209
|
throw err;
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
/** How long a SIGTERM'd liveware process tree gets before SIGKILL. */
|
|
213
|
+
export const KILL_GRACE_MS = 3_000;
|
|
214
|
+
const KILL_POLL_MS = 50;
|
|
212
215
|
/**
|
|
213
|
-
* Terminate a child and everything it spawned
|
|
216
|
+
* Terminate a child and everything it spawned; resolves once the tree is gone
|
|
217
|
+
* (POSIX) or the kill has been issued (Windows).
|
|
214
218
|
*
|
|
215
|
-
* POSIX:
|
|
219
|
+
* POSIX: the liveware children are spawned `detached` (see
|
|
220
|
+
* {@link childSpawnOptions}), so each leads its own process group and
|
|
221
|
+
* `kill(-pid)` reaches every grandchild the liveware agent started. SIGTERM
|
|
222
|
+
* first; anything still alive after `graceMs` gets SIGKILL — a child that
|
|
223
|
+
* ignores SIGTERM used to keep the gateway's stop (and systemd's restart)
|
|
224
|
+
* waiting for the full unit timeout. A child that is not a group leader (no
|
|
225
|
+
* pid, or spawned without `detached`) is signalled directly instead.
|
|
216
226
|
*
|
|
217
227
|
* Windows: there are no signals. `child.kill()` maps to TerminateProcess on
|
|
218
228
|
* that one process, so any grandchild the liveware agent spawned survives —
|
|
@@ -220,23 +230,86 @@ export async function downloadLivewareSample(opts) {
|
|
|
220
230
|
* which then fails to bind. `taskkill /T /F` takes the whole tree down.
|
|
221
231
|
* Fire-and-forget: we never wait on it, and a failure (already dead, no such
|
|
222
232
|
* pid) falls through to the ordinary kill().
|
|
233
|
+
*
|
|
234
|
+
* The first signal is sent synchronously, before the returned promise is
|
|
235
|
+
* first awaited, so fire-and-forget callers keep their old behaviour.
|
|
223
236
|
*/
|
|
224
|
-
export function killProcessTree(child, execFileFn = nodeExecFile) {
|
|
237
|
+
export function killProcessTree(child, execFileFn = nodeExecFile, opts = {}) {
|
|
225
238
|
if (!child)
|
|
226
|
-
return;
|
|
239
|
+
return Promise.resolve();
|
|
227
240
|
if (process.platform === "win32" && typeof child.pid === "number") {
|
|
228
241
|
try {
|
|
229
242
|
execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => { });
|
|
230
|
-
return;
|
|
243
|
+
return Promise.resolve();
|
|
231
244
|
}
|
|
232
245
|
catch {
|
|
233
246
|
// taskkill missing or spawn refused — fall back to the plain kill below.
|
|
234
247
|
}
|
|
235
248
|
}
|
|
236
|
-
|
|
237
|
-
|
|
249
|
+
if (process.platform === "win32") {
|
|
250
|
+
try {
|
|
251
|
+
child.kill("SIGTERM");
|
|
252
|
+
}
|
|
253
|
+
catch { /* already dead */ }
|
|
254
|
+
return Promise.resolve();
|
|
238
255
|
}
|
|
239
|
-
|
|
256
|
+
const killFn = opts.killFn ?? ((pid, signal) => { process.kill(pid, signal); });
|
|
257
|
+
const graceMs = opts.graceMs ?? KILL_GRACE_MS;
|
|
258
|
+
const pid = typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
|
|
259
|
+
let exited = child.exitCode != null || child.signalCode != null;
|
|
260
|
+
const onExit = () => { exited = true; };
|
|
261
|
+
child.once("exit", onExit);
|
|
262
|
+
const groupAlive = () => {
|
|
263
|
+
if (pid === null)
|
|
264
|
+
return false;
|
|
265
|
+
try {
|
|
266
|
+
killFn(-pid, 0);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const signalTree = (signal) => {
|
|
274
|
+
let reachedGroup = false;
|
|
275
|
+
if (pid !== null) {
|
|
276
|
+
try {
|
|
277
|
+
killFn(-pid, signal);
|
|
278
|
+
reachedGroup = true;
|
|
279
|
+
}
|
|
280
|
+
catch { /* not a group leader, or gone */ }
|
|
281
|
+
}
|
|
282
|
+
if (!reachedGroup && !exited) {
|
|
283
|
+
try {
|
|
284
|
+
child.kill(signal);
|
|
285
|
+
}
|
|
286
|
+
catch { /* already dead */ }
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const treeAlive = () => !exited || groupAlive();
|
|
290
|
+
signalTree("SIGTERM");
|
|
291
|
+
return (async () => {
|
|
292
|
+
try {
|
|
293
|
+
const deadline = Date.now() + graceMs;
|
|
294
|
+
while (treeAlive() && Date.now() < deadline) {
|
|
295
|
+
await new Promise((r) => setTimeout(r, Math.min(KILL_POLL_MS, Math.max(1, deadline - Date.now()))));
|
|
296
|
+
}
|
|
297
|
+
if (treeAlive())
|
|
298
|
+
signalTree("SIGKILL");
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
child.removeListener("exit", onExit);
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Spawn options shared by the long-lived liveware children. POSIX: `detached`
|
|
307
|
+
* makes the child a process-group leader so {@link killProcessTree} can reach
|
|
308
|
+
* its grandchildren with `kill(-pid)`. Windows keeps the old non-detached
|
|
309
|
+
* spawn (detached would open a console window there) and relies on taskkill.
|
|
310
|
+
*/
|
|
311
|
+
function childSpawnOptions() {
|
|
312
|
+
return process.platform === "win32" ? {} : { detached: true };
|
|
240
313
|
}
|
|
241
314
|
const SERVER_START_TIMEOUT_MS = 10_000;
|
|
242
315
|
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
@@ -255,7 +328,7 @@ function waitForOutput(child, match, timeoutMs, label) {
|
|
|
255
328
|
child.stderr?.removeListener("data", onData);
|
|
256
329
|
child.removeListener("exit", onExit);
|
|
257
330
|
if (err) {
|
|
258
|
-
killProcessTree(child);
|
|
331
|
+
void killProcessTree(child);
|
|
259
332
|
reject(err);
|
|
260
333
|
}
|
|
261
334
|
else {
|
|
@@ -319,7 +392,7 @@ export async function startSampleServer(opts) {
|
|
|
319
392
|
const args = [path.join(opts.appDir, "server.mjs"), "--dir", opts.appDir, "--port", String(opts.port)];
|
|
320
393
|
if (opts.agentUserId)
|
|
321
394
|
args.push("--agent-id", opts.agentUserId);
|
|
322
|
-
const child = spawnFn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
395
|
+
const child = spawnFn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"], ...childSpawnOptions() });
|
|
323
396
|
const line = await waitForOutput(child, (acc) => acc.split("\n").find((l) => l.trim().startsWith('{"port"')) ?? null, opts.timeoutMs ?? SERVER_START_TIMEOUT_MS, "liveware-sample server start");
|
|
324
397
|
return { child, port: JSON.parse(line).port };
|
|
325
398
|
}
|
|
@@ -387,7 +460,7 @@ export function parseAgentReady(output) {
|
|
|
387
460
|
*/
|
|
388
461
|
export async function startTunnelAgent(opts) {
|
|
389
462
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
390
|
-
const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"], ...(opts.env ? { env: opts.env } : {}) });
|
|
463
|
+
const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"], ...childSpawnOptions(), ...(opts.env ? { env: opts.env } : {}) });
|
|
391
464
|
await waitForOutput(child, (acc) => parseAgentReady(acc), opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS, "liveware agent start");
|
|
392
465
|
return { child };
|
|
393
466
|
}
|
|
@@ -629,6 +702,8 @@ export class LivewareSampleSupervisor {
|
|
|
629
702
|
serverChild = null;
|
|
630
703
|
tunnelChild = null;
|
|
631
704
|
stopped = false;
|
|
705
|
+
/** The one shutdown wait, shared by every stop() caller. */
|
|
706
|
+
stopping = null;
|
|
632
707
|
/** True while a startAttempt chain (bootstrap/relaunch) is running; read by
|
|
633
708
|
* isIdle() so adoptDeps never starts a second concurrent flow. */
|
|
634
709
|
launchInFlight = false;
|
|
@@ -702,7 +777,7 @@ export class LivewareSampleSupervisor {
|
|
|
702
777
|
catch (err) {
|
|
703
778
|
// A mid-flow failure (e.g. registerApp rejected) may have left children
|
|
704
779
|
// running without a persisted row — never leak orphan processes.
|
|
705
|
-
this.killChildren();
|
|
780
|
+
void this.killChildren();
|
|
706
781
|
const delayMs = START_RETRY_DELAYS_MS[attempt];
|
|
707
782
|
if (delayMs === undefined || this.stopped) {
|
|
708
783
|
deps.log?.warn?.(`clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, giving up): ${String(err)}`);
|
|
@@ -715,20 +790,29 @@ export class LivewareSampleSupervisor {
|
|
|
715
790
|
this.launchInFlight = false;
|
|
716
791
|
}
|
|
717
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* Stop for good and resolve once both children (and their process groups)
|
|
795
|
+
* are gone — SIGTERM, then SIGKILL after `killGraceMs`. Idempotent: the abort
|
|
796
|
+
* listener and the gateway's own shutdown both call it and await one wait.
|
|
797
|
+
*/
|
|
718
798
|
stop() {
|
|
799
|
+
if (this.stopping)
|
|
800
|
+
return this.stopping;
|
|
719
801
|
this.stopped = true;
|
|
720
802
|
for (const t of this.timers)
|
|
721
803
|
clearTimeout(t);
|
|
722
804
|
this.timers.clear();
|
|
723
|
-
this.killChildren();
|
|
805
|
+
this.stopping = this.killChildren();
|
|
806
|
+
return this.stopping;
|
|
724
807
|
}
|
|
725
808
|
killChildren() {
|
|
726
809
|
this.generation += 1; // invalidate pending exit handlers before killing
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
810
|
+
const kills = [this.serverChild, this.tunnelChild].map((child) => killProcessTree(child, this.deps.execFileFn, {
|
|
811
|
+
...(this.deps.killGraceMs !== undefined ? { graceMs: this.deps.killGraceMs } : {}),
|
|
812
|
+
}));
|
|
730
813
|
this.serverChild = null;
|
|
731
814
|
this.tunnelChild = null;
|
|
815
|
+
return Promise.all(kills).then(() => undefined);
|
|
732
816
|
}
|
|
733
817
|
schedule(fn, delayMs) {
|
|
734
818
|
if (this.stopped)
|
|
@@ -743,7 +827,10 @@ export class LivewareSampleSupervisor {
|
|
|
743
827
|
bailIfStopped() {
|
|
744
828
|
if (!this.stopped)
|
|
745
829
|
return false;
|
|
746
|
-
this
|
|
830
|
+
// Fold this late kill into the shared shutdown wait so a stop() caller
|
|
831
|
+
// that awaits after this point also waits for it.
|
|
832
|
+
const late = this.killChildren();
|
|
833
|
+
this.stopping = Promise.all([this.stopping, late]).then(() => undefined);
|
|
747
834
|
return true;
|
|
748
835
|
}
|
|
749
836
|
/**
|
|
@@ -989,7 +1076,7 @@ export class LivewareSampleSupervisor {
|
|
|
989
1076
|
const onChildExit = () => {
|
|
990
1077
|
if (this.stopped || gen !== this.generation)
|
|
991
1078
|
return;
|
|
992
|
-
this.killChildren();
|
|
1079
|
+
void this.killChildren();
|
|
993
1080
|
const now = Date.now();
|
|
994
1081
|
this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
|
|
995
1082
|
if (this.restartTimes.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
@@ -1020,7 +1107,7 @@ export class LivewareSampleSupervisor {
|
|
|
1020
1107
|
.catch((err) => {
|
|
1021
1108
|
// A partially-completed relaunch may have spawned children before
|
|
1022
1109
|
// failing — never leave them running unwatched.
|
|
1023
|
-
this.killChildren();
|
|
1110
|
+
void this.killChildren();
|
|
1024
1111
|
this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
|
|
1025
1112
|
this.deps.store.updateLivewareSampleStatus({
|
|
1026
1113
|
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** The `@everyone` sentinel user id; never auto-linked from text. */
|
|
2
|
+
export const MENTION_ALL_SENTINEL = "all";
|
|
3
|
+
const AT = 0x40;
|
|
4
|
+
function isAsciiAlnum(c) {
|
|
5
|
+
return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
|
|
6
|
+
}
|
|
7
|
+
function isEmailLocal(c) {
|
|
8
|
+
return isAsciiAlnum(c) || c === 0x2e || c === 0x5f || c === 0x25 || c === 0x2b || c === 0x2d;
|
|
9
|
+
}
|
|
10
|
+
function openBoundary(text, at) {
|
|
11
|
+
if (at === 0)
|
|
12
|
+
return true;
|
|
13
|
+
return !isEmailLocal(text.charCodeAt(at - 1));
|
|
14
|
+
}
|
|
15
|
+
function lowerAscii(c) {
|
|
16
|
+
return c >= 0x41 && c <= 0x5a ? c + 0x20 : c;
|
|
17
|
+
}
|
|
18
|
+
function matches(text, start, name, fold) {
|
|
19
|
+
const end = start + name.length;
|
|
20
|
+
if (end > text.length)
|
|
21
|
+
return false;
|
|
22
|
+
for (let k = 0; k < name.length; k += 1) {
|
|
23
|
+
let a = text.charCodeAt(start + k);
|
|
24
|
+
let b = name.charCodeAt(k);
|
|
25
|
+
if (fold) {
|
|
26
|
+
a = lowerAscii(a);
|
|
27
|
+
b = lowerAscii(b);
|
|
28
|
+
}
|
|
29
|
+
if (a !== b)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (end === text.length)
|
|
33
|
+
return true;
|
|
34
|
+
return !(isAsciiAlnum(name.charCodeAt(name.length - 1)) && isAsciiAlnum(text.charCodeAt(end)));
|
|
35
|
+
}
|
|
36
|
+
function matchAt(text, start, targets) {
|
|
37
|
+
let best = null;
|
|
38
|
+
let bestExact = false;
|
|
39
|
+
let ambiguous = false;
|
|
40
|
+
for (const t of targets) {
|
|
41
|
+
const exact = matches(text, start, t.name, false);
|
|
42
|
+
const hit = exact || matches(text, start, t.name, true);
|
|
43
|
+
if (!hit)
|
|
44
|
+
continue;
|
|
45
|
+
if (!best ||
|
|
46
|
+
t.name.length > best.name.length ||
|
|
47
|
+
(t.name.length === best.name.length && exact && !bestExact)) {
|
|
48
|
+
best = t;
|
|
49
|
+
bestExact = exact;
|
|
50
|
+
ambiguous = false;
|
|
51
|
+
}
|
|
52
|
+
else if (t.name.length === best.name.length && exact === bestExact && t.userId !== best.userId) {
|
|
53
|
+
ambiguous = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ambiguous ? null : best;
|
|
57
|
+
}
|
|
58
|
+
function targetsOf(roster, ownUserId) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of roster) {
|
|
61
|
+
const name = typeof m.name === "string" ? m.name.trim() : "";
|
|
62
|
+
const userId = typeof m.userId === "string" ? m.userId.trim() : "";
|
|
63
|
+
if (!name || !userId)
|
|
64
|
+
continue;
|
|
65
|
+
if (userId === ownUserId)
|
|
66
|
+
continue;
|
|
67
|
+
if (userId === MENTION_ALL_SENTINEL)
|
|
68
|
+
continue;
|
|
69
|
+
out.push({ userId, name });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Whether `text` has an `@` worth fetching the group roster for. */
|
|
74
|
+
export function hasMentionCandidate(text) {
|
|
75
|
+
for (let i = 0; i < text.length - 1; i += 1) {
|
|
76
|
+
if (text.charCodeAt(i) === AT && openBoundary(text, i))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Split `text` into text / mention fragments using `roster`. The `@` is
|
|
83
|
+
* consumed by the mention fragment (`display` is the bare name; clients
|
|
84
|
+
* render `@display`). With no hit the result is a single text fragment
|
|
85
|
+
* (or `[]` for empty text).
|
|
86
|
+
*/
|
|
87
|
+
export function autolinkMentions(text, roster, options) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return [];
|
|
90
|
+
const targets = targetsOf(roster, options.ownUserId);
|
|
91
|
+
if (targets.length === 0)
|
|
92
|
+
return [{ kind: "text", text }];
|
|
93
|
+
const out = [];
|
|
94
|
+
let cursor = 0;
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text.charCodeAt(i) !== AT || !openBoundary(text, i)) {
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const hit = matchAt(text, i + 1, targets);
|
|
102
|
+
if (!hit) {
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (i > cursor)
|
|
107
|
+
out.push({ kind: "text", text: text.slice(cursor, i) });
|
|
108
|
+
out.push({ kind: "mention", user_id: hit.userId, display: hit.name });
|
|
109
|
+
i += 1 + hit.name.length;
|
|
110
|
+
cursor = i;
|
|
111
|
+
}
|
|
112
|
+
if (cursor < text.length)
|
|
113
|
+
out.push({ kind: "text", text: text.slice(cursor) });
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** The `context.mentions` half: mention fragments de-duplicated by user id. */
|
|
117
|
+
export function mentionsIn(fragments) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const f of fragments) {
|
|
121
|
+
if (f.kind !== "mention")
|
|
122
|
+
continue;
|
|
123
|
+
const id = typeof f.user_id === "string" ? f.user_id : "";
|
|
124
|
+
if (!id || seen.has(id))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(id);
|
|
127
|
+
out.push({ kind: "mention", user_id: id, ...(f.display ? { display: f.display } : {}) });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
const rosterResolvers = new Map();
|
|
132
|
+
/** Register the roster source for one account; returns an unregister function. */
|
|
133
|
+
export function registerMentionRosterResolver(accountId, resolver) {
|
|
134
|
+
rosterResolvers.set(accountId, resolver);
|
|
135
|
+
return () => {
|
|
136
|
+
if (rosterResolvers.get(accountId) === resolver)
|
|
137
|
+
rosterResolvers.delete(accountId);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Best-effort roster lookup: any failure or missing resolver yields `[]`. */
|
|
141
|
+
export async function resolveMentionRoster(accountId, groupId) {
|
|
142
|
+
const resolver = rosterResolvers.get(accountId);
|
|
143
|
+
if (!resolver)
|
|
144
|
+
return [];
|
|
145
|
+
try {
|
|
146
|
+
const roster = await resolver(groupId);
|
|
147
|
+
return Array.isArray(roster) ? roster : [];
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function clearMentionRosterResolversForTest() {
|
|
154
|
+
rosterResolvers.clear();
|
|
155
|
+
}
|
package/dist/src/no-reply.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* tolerated for free and must not be written into the pattern itself.
|
|
10
10
|
*
|
|
11
11
|
* RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
|
|
12
|
-
* `NO REPLY`), matched as a WHOLE STRING ONLY.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* `NO REPLY` / `HEARTBEAT_OK`), matched as a WHOLE STRING ONLY. These are
|
|
13
|
+
* carried because agent runtimes define them, so a model may fall back to one
|
|
14
|
+
* instead of the `clawchat:` form; `HEARTBEAT_OK` is the OpenClaw host's
|
|
15
|
+
* heartbeat acknowledgement, which some host versions let through as a reply.
|
|
16
|
+
* They are ordinary words — substring-matching them would swallow prose such as
|
|
17
|
+
* "there is no reply from the server".
|
|
17
18
|
*
|
|
18
19
|
* This module MUST stay a literal mirror of the Hermes plugin's equivalent
|
|
19
20
|
* module. When one side changes, change the other in the same breath.
|
|
@@ -33,7 +34,7 @@ const CONTAINS_RE = new RegExp(CORE, "i");
|
|
|
33
34
|
const DECORATION_OPEN = "[<\\[{`*_~]*";
|
|
34
35
|
const DECORATION_CLOSE = "[/>\\]}`*_~]*";
|
|
35
36
|
const STRIP_RE = new RegExp(DECORATION_OPEN + CORE + DECORATION_CLOSE, "gi");
|
|
36
|
-
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"]);
|
|
37
|
+
const HOST_MARKERS = new Set(["[SILENT]", "SILENT", "NO_REPLY", "NO REPLY", "HEARTBEAT_OK"]);
|
|
37
38
|
const HOST_MARKER_MAX_LEN = 64;
|
|
38
39
|
// General_Category=Punctuation, matching the host's `unicodedata.category()`
|
|
39
40
|
// check. Note `*` is Po (stripped) while `~` is Sm (kept) — same as the host.
|