@clawling/clawchat-plugin-openclaw 2026.7.29-1 → 2026.8.2-1

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.
@@ -361,6 +361,9 @@ export function createOpenclawClawlingApiClient(opts) {
361
361
  headers: { "content-type": "application/json" },
362
362
  });
363
363
  },
364
+ async getMoment(momentId) {
365
+ return await call("GET", `/v1/moments/${encodeURIComponent(String(momentId))}`);
366
+ },
364
367
  async deleteMoment(momentId) {
365
368
  return await call("DELETE", `/v1/moments/${encodeURIComponent(String(momentId))}`);
366
369
  },
@@ -16,7 +16,7 @@ export function buildAwarenessNoteEnvelope(params) {
16
16
  event: EVENT.MESSAGE_SEND,
17
17
  trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
18
18
  emitted_at: now,
19
- chat_id: ownerConversationId ?? account.ownerUserId,
19
+ chat_id: ownerConversationId,
20
20
  chat_type: "direct",
21
21
  to: { id: account.userId, type: "direct" },
22
22
  sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
@@ -44,3 +44,29 @@ export function buildAwarenessNoteEnvelope(params) {
44
44
  },
45
45
  };
46
46
  }
47
+ export function buildMomentCommentNoteEnvelope(params) {
48
+ const { account, ownerConversationId, momentId, replied } = params;
49
+ const now = Date.now();
50
+ const text = replied
51
+ ? `ClawChat: someone replied to your comment on a moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`
52
+ : `ClawChat: someone commented on your moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`;
53
+ return {
54
+ version: "2",
55
+ event: EVENT.MESSAGE_SEND,
56
+ trace_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
57
+ emitted_at: now,
58
+ chat_id: ownerConversationId,
59
+ chat_type: "direct",
60
+ to: { id: account.userId, type: "direct" },
61
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
62
+ payload: {
63
+ message_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
64
+ message_mode: "normal",
65
+ message: {
66
+ body: { fragments: [{ kind: "text", text }] },
67
+ context: { mentions: [], reply: null },
68
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
69
+ },
70
+ },
71
+ };
72
+ }
@@ -32,6 +32,7 @@ export function createOpenclawClawlingClient(account, overrides = {}) {
32
32
  timeout: account.ack.timeout,
33
33
  autoResendOnTimeout: account.ack.autoResendOnTimeout,
34
34
  },
35
+ ...(overrides.now ? { now: overrides.now } : {}),
35
36
  });
36
37
  if (overrides.wsLifecycle?.onConnectFrameSent) {
37
38
  const sendRawEnvelope = client.sendRawEnvelope.bind(client);
@@ -62,20 +62,67 @@ async function fetchText(fetchFn, url) {
62
62
  }
63
63
  return text;
64
64
  }
65
+ /**
66
+ * True when `appDir` already holds what the manifest pins: every listed file
67
+ * present, program files byte-identical to their manifest sha256, and nothing
68
+ * beyond those files (so a file dropped by a newer manifest still forces a clean
69
+ * reinstall). USER_DATA_FILES are checked for presence only, never for content —
70
+ * `state.json` ships in the manifest *and* is owned by the agent afterwards, so
71
+ * hashing it would make the short-circuit miss on exactly the installs that are
72
+ * actually in use. Cheap (~20 KB of local reads) next to the per-file GETs it
73
+ * saves, and fail-safe: any read error answers "no".
74
+ */
75
+ async function localInstallMatches(appDir, entry) {
76
+ try {
77
+ const expected = new Set(entry.files.map((f) => path.basename(f.path)));
78
+ for (const name of await fs.promises.readdir(appDir)) {
79
+ if (!expected.has(name) && !USER_DATA_FILES.includes(name))
80
+ return false;
81
+ }
82
+ for (const file of entry.files) {
83
+ const name = path.basename(file.path);
84
+ const target = path.join(appDir, name);
85
+ if (USER_DATA_FILES.includes(name)) {
86
+ await fs.promises.access(target); // agent-owned: presence is the whole check
87
+ continue;
88
+ }
89
+ const buf = await fs.promises.readFile(target);
90
+ if (crypto.createHash("sha256").update(buf).digest("hex") !== file.sha256)
91
+ return false;
92
+ }
93
+ return true;
94
+ }
95
+ catch {
96
+ return false; // missing / unreadable — treat as "not installed"
97
+ }
98
+ }
65
99
  /**
66
100
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
67
101
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
68
102
  * never leaves a partially-written `app/` dir behind.
103
+ *
104
+ * Re-install short-circuit: when the installed bytes already match every hash
105
+ * the manifest pins, nothing is fetched or rewritten (`reused: true`). Every
106
+ * boot used to re-fetch the whole bundle from GitHub raw even when the identical
107
+ * files were already on disk — a handful of serial round-trips on the critical
108
+ * path, and an unreachable raw.githubusercontent then failed a sample that was
109
+ * already fully installed. Only the manifest itself is still fetched every time,
110
+ * because that is what tells us which hashes to compare against.
69
111
  */
70
112
  export async function downloadLivewareSample(opts) {
71
113
  const ref = opts.ref ?? DEFAULT_SKILLS_REF;
72
114
  const entry = parseLivewaresManifest(await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)));
73
115
  const appDir = path.join(opts.sampleRoot, "app");
116
+ if (await localInstallMatches(appDir, entry)) {
117
+ return { version: entry.version, appDir, reused: true };
118
+ }
74
119
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
75
120
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
76
121
  await fs.promises.mkdir(tmpDir, { recursive: true });
77
122
  try {
78
- for (const file of entry.files) {
123
+ // Fetched concurrently: the files are independent and each is a separate
124
+ // round-trip to the same host, so serializing them only added latency.
125
+ await Promise.all(entry.files.map(async (file) => {
79
126
  const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
80
127
  const buf = Buffer.from(text, "utf8");
81
128
  const actual = crypto.createHash("sha256").update(buf).digest("hex");
@@ -83,7 +130,7 @@ export async function downloadLivewareSample(opts) {
83
130
  throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
84
131
  }
85
132
  await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
86
- }
133
+ }));
87
134
  // Preserve agent/user-owned data files from a previous install.
88
135
  for (const name of USER_DATA_FILES) {
89
136
  const prev = path.join(appDir, name);
@@ -276,6 +323,87 @@ function findIdDeep(value) {
276
323
  }
277
324
  return null;
278
325
  }
326
+ /**
327
+ * Find the id of an existing liveware app named `name` in `liveware app list`
328
+ * output. Deliberately conservative: a shape this cannot read must degrade to
329
+ * "not found" — the caller then creates an app, i.e. the previous behaviour —
330
+ * and must never return a WRONG id, so the text branch only accepts a line that
331
+ * contains the exact app name plus one id-looking token outside it, and an
332
+ * id-looking token must carry a digit/`-`/`_` so a status word ("active",
333
+ * "running") cannot pass for an id. JSON output (any wrapper key) is the
334
+ * reliable path; the aligned-table branch is calibrated on `app create`'s table,
335
+ * the only text shape verified against the real CLI.
336
+ */
337
+ export function findAppIdByName(stdout, name) {
338
+ const text = (stdout ?? "").trim();
339
+ const wanted = (name ?? "").trim();
340
+ if (!text || !wanted)
341
+ return null;
342
+ try {
343
+ const found = findNamedIdDeep(JSON.parse(text), wanted);
344
+ if (found)
345
+ return found;
346
+ }
347
+ catch { /* not JSON */ }
348
+ for (const line of text.split("\n")) {
349
+ const at = line.indexOf(wanted);
350
+ if (at < 0)
351
+ continue;
352
+ const outsideName = `${line.slice(0, at)} ${line.slice(at + wanted.length)}`;
353
+ for (const token of outsideName.trim().split(/\s+/)) {
354
+ if (isAppIdToken(token))
355
+ return token;
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+ function isAppIdToken(token) {
361
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(token) && /[0-9_-]/.test(token);
362
+ }
363
+ function findNamedIdDeep(value, name) {
364
+ if (Array.isArray(value)) {
365
+ for (const item of value) {
366
+ const found = findNamedIdDeep(item, name);
367
+ if (found)
368
+ return found;
369
+ }
370
+ return null;
371
+ }
372
+ if (!value || typeof value !== "object")
373
+ return null;
374
+ const obj = value;
375
+ const named = ["name", "app_name", "appName", "title"].some((key) => typeof obj[key] === "string" && obj[key].trim() === name);
376
+ if (named) {
377
+ const id = findIdDeep(obj);
378
+ if (id)
379
+ return id;
380
+ }
381
+ for (const nested of Object.values(obj)) {
382
+ const found = findNamedIdDeep(nested, name);
383
+ if (found)
384
+ return found;
385
+ }
386
+ return null;
387
+ }
388
+ /**
389
+ * `liveware app list` → the id of an existing app with this name, or null.
390
+ * Best-effort by contract: an exec failure or unreadable output resolves to null
391
+ * so the caller falls back to `app create` instead of failing the whole flow.
392
+ */
393
+ export async function livewareAppFindByName(opts) {
394
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
395
+ const stdout = await new Promise((resolve) => {
396
+ execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
397
+ if (err) {
398
+ opts.log?.debug?.(`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`);
399
+ resolve(null);
400
+ return;
401
+ }
402
+ resolve(String(out ?? ""));
403
+ });
404
+ });
405
+ return stdout == null ? null : findAppIdByName(stdout, opts.name);
406
+ }
279
407
  export async function livewareAppCreate(opts) {
280
408
  const execFileFn = opts.execFileFn ?? nodeExecFile;
281
409
  const stdout = await new Promise((resolve, reject) => {
@@ -305,10 +433,15 @@ const INTRO_MAX_TRIES = 20;
305
433
  // a one-shot bootstrap would otherwise stay dormant until the next restart.
306
434
  const START_RETRY_DELAYS_MS = [30_000, 60_000, 120_000, 300_000];
307
435
  export class LivewareSampleSupervisor {
436
+ /** Not readonly: adoptDeps() re-points a live supervisor at a re-entered
437
+ * gateway closure (fresh token / REST client / owner-notify path). */
308
438
  deps;
309
439
  serverChild = null;
310
440
  tunnelChild = null;
311
441
  stopped = false;
442
+ /** True while a startAttempt chain (bootstrap/relaunch) is running; read by
443
+ * isIdle() so adoptDeps never starts a second concurrent flow. */
444
+ launchInFlight = false;
312
445
  restartTimes = [];
313
446
  timers = new Set();
314
447
  /** Bumped on every (re)launch and on killChildren: a child's exit handler
@@ -323,8 +456,39 @@ export class LivewareSampleSupervisor {
323
456
  async start() {
324
457
  await this.startAttempt(0);
325
458
  }
459
+ /** Re-point a live supervisor at a freshly re-entered gateway closure, and —
460
+ * only when nothing is in flight — kick a new attempt.
461
+ *
462
+ * `startOpenclawClawlingGateway` re-enters itself on token refresh / transport
463
+ * backoff, so runtime.ts keeps ONE supervisor per (abortSignal, account) and
464
+ * hands the new closure here instead of constructing a second supervisor: two
465
+ * supervisors raced the bootstrap "account has zero apps" gate and registered
466
+ * two liveware apps for the same account, one of which no row then tracked.
467
+ * The re-entered closure owns the fresh token, REST client and owner-notify
468
+ * path; children, timers and the crash-restart budget keep running untouched.
469
+ * A flow already mid-await finishes on the deps it captured. */
470
+ adoptDeps(deps) {
471
+ this.deps = deps;
472
+ // A re-entry used to re-run the whole flow through the brand-new supervisor,
473
+ // which is what healed an attempt that had skipped (no token yet) or burnt
474
+ // through its retry ladder. Keep that healing, but only when there is
475
+ // genuinely nothing live: no in-flight flow, no pending retry/crash-restart
476
+ // timer, no children — a second concurrent flow is exactly the duplicate
477
+ // registration the single-supervisor registry exists to prevent.
478
+ if (this.isIdle())
479
+ void this.startAttempt(0);
480
+ }
481
+ /** Nothing running and nothing scheduled: safe to (re)start a flow. */
482
+ isIdle() {
483
+ return !this.stopped
484
+ && !this.launchInFlight
485
+ && this.timers.size === 0
486
+ && this.serverChild === null
487
+ && this.tunnelChild === null;
488
+ }
326
489
  async startAttempt(attempt) {
327
490
  const { deps } = this;
491
+ this.launchInFlight = true;
328
492
  try {
329
493
  if (!deps.enabled)
330
494
  return;
@@ -357,6 +521,9 @@ export class LivewareSampleSupervisor {
357
521
  deps.log?.warn?.(`clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, retrying in ${delayMs / 1000}s): ${String(err)}`);
358
522
  this.schedule(() => { void this.startAttempt(attempt + 1); }, delayMs);
359
523
  }
524
+ finally {
525
+ this.launchInFlight = false;
526
+ }
360
527
  }
361
528
  stop() {
362
529
  this.stopped = true;
@@ -396,24 +563,18 @@ export class LivewareSampleSupervisor {
396
563
  const { deps } = this;
397
564
  if (this.stopped)
398
565
  return;
566
+ // Retryable-vs-opt-out: a missing CLI, an absent token and a flaky listApps
567
+ // are all transient, but they used to `return`, which left the sample dormant
568
+ // for the whole process lifetime (nothing re-enters bootstrap on its own).
569
+ // Throw instead so startAttempt's START_RETRY_DELAYS_MS ladder gets its turn;
570
+ // only a genuine opt-out (disabled row, owner already has apps) returns.
399
571
  const livewarePath = deps.resolveLivewarePath();
400
- if (!livewarePath) {
401
- deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip");
402
- return;
403
- }
572
+ if (!livewarePath)
573
+ throw new Error("liveware CLI not ready");
404
574
  const token = deps.resolveToken();
405
- if (!token) {
406
- deps.log?.debug?.("liveware-sample: no token; skip");
407
- return;
408
- }
409
- let apps;
410
- try {
411
- apps = await deps.listApps();
412
- }
413
- catch (err) {
414
- deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
415
- return;
416
- }
575
+ if (!token)
576
+ throw new Error("no ClawChat token available yet");
577
+ const apps = await deps.listApps();
417
578
  if (this.bailIfStopped())
418
579
  return;
419
580
  if (apps.apps.length > 0) {
@@ -433,9 +594,31 @@ export class LivewareSampleSupervisor {
433
594
  if (this.bailIfStopped())
434
595
  return;
435
596
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
436
- const appId = await livewareAppCreate({
597
+ // Reuse an app we already own before minting another one. `app create` is not
598
+ // idempotent and nothing here ever reconciled against the liveware side, so
599
+ // every bootstrap that died past this point (no row was written until the very
600
+ // end, see below) left one more orphan app behind and ate into the account's
601
+ // app quota. Best-effort: an unreadable list falls through to `app create`.
602
+ const existingAppId = await livewareAppFindByName({
603
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
604
+ });
605
+ if (this.bailIfStopped())
606
+ return;
607
+ if (existingAppId) {
608
+ deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
609
+ }
610
+ const appId = existingAppId ?? await livewareAppCreate({
437
611
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
438
612
  });
613
+ // Persist the app id BEFORE the steps that can still fail. Without this a
614
+ // failure anywhere below left no row at all, so the next boot ran bootstrap
615
+ // again and created yet another app; a "pending" row makes the next attempt
616
+ // resume through relaunch() with the same app id instead.
617
+ deps.store.upsertLivewareSample({
618
+ platform: deps.platform, accountId: deps.accountId,
619
+ appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
620
+ publicUrl: null, sampleVersion: version, status: "pending",
621
+ });
439
622
  const publicUrl = await tunnelBind({
440
623
  livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
441
624
  });
@@ -459,24 +642,23 @@ export class LivewareSampleSupervisor {
459
642
  const { deps } = this;
460
643
  if (this.stopped)
461
644
  return;
645
+ // Both of these used to `return` and strand the sample until the next process
646
+ // start; they are transient, so throw and let the retry ladder run (see
647
+ // bootstrap). Only a successful list that no longer contains the app is an
648
+ // opt-out.
462
649
  const livewarePath = deps.resolveLivewarePath();
463
- if (!livewarePath) {
464
- deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip relaunch");
465
- return;
466
- }
650
+ if (!livewarePath)
651
+ throw new Error("liveware CLI not ready");
652
+ const apps = await deps.listApps();
467
653
  // The user deleting the app tile is an explicit opt-out — never reinstall.
468
- try {
469
- const apps = await deps.listApps();
470
- if (!apps.apps.some((a) => a.app_id === row.app_id)) {
471
- deps.store.updateLivewareSampleStatus({
472
- platform: deps.platform, accountId: deps.accountId, status: "disabled",
473
- lastError: "app removed by user",
474
- });
475
- return;
476
- }
477
- }
478
- catch (err) {
479
- deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
654
+ // Exception: a "pending" row was persisted mid-bootstrap, before the app was
655
+ // ever registered with ClawChat, so its absence from this list means "not
656
+ // registered yet", not "deleted" — disabling it there would strand the app id.
657
+ if (row.status !== "pending" && !apps.apps.some((a) => a.app_id === row.app_id)) {
658
+ deps.store.updateLivewareSampleStatus({
659
+ platform: deps.platform, accountId: deps.accountId, status: "disabled",
660
+ lastError: "app removed by user",
661
+ });
480
662
  return;
481
663
  }
482
664
  if (this.bailIfStopped())
@@ -504,8 +686,46 @@ export class LivewareSampleSupervisor {
504
686
  this.serverChild = server.child;
505
687
  if (this.bailIfStopped())
506
688
  return;
689
+ // Re-login before touching the CLI. Its credentials live in $HOME/.clawling,
690
+ // a different volume from the stateDir holding the row that sent us down this
691
+ // path: a container that persists stateDir but not $HOME comes back with a row
692
+ // AND a logged-out CLI, so every tunnelBind/agent below fails auth until the
693
+ // retry ladder gives up — the sample then never returns for the whole process
694
+ // lifetime. `login` is idempotent; best-effort, so a stale token still falls
695
+ // through to whatever credentials the CLI already has.
696
+ const token = deps.resolveToken();
697
+ if (token) {
698
+ try {
699
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
700
+ }
701
+ catch (err) {
702
+ deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`);
703
+ }
704
+ if (this.bailIfStopped())
705
+ return;
706
+ }
707
+ else {
708
+ deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
709
+ }
710
+ // Kept after the re-login above so the lookup runs against a CLI that is
711
+ // actually authenticated: a pending row's app id came straight from
712
+ // `app create`'s parser and was never confirmed by the liveware side, so
713
+ // prefer a by-name lookup — a mis-parsed id then self-heals instead of
714
+ // failing every tunnel bind for the rest of this row's life.
715
+ let appId = row.app_id;
716
+ if (row.status === "pending") {
717
+ const found = await livewareAppFindByName({
718
+ livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
719
+ });
720
+ if (found && found !== appId) {
721
+ deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
722
+ appId = found;
723
+ }
724
+ if (this.bailIfStopped())
725
+ return;
726
+ }
507
727
  const publicUrl = await tunnelBind({
508
- livewarePath, appId: row.app_id, port: server.port, execFileFn: deps.execFileFn,
728
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
509
729
  });
510
730
  if (this.bailIfStopped())
511
731
  return;
@@ -513,14 +733,16 @@ export class LivewareSampleSupervisor {
513
733
  this.tunnelChild = agent.child;
514
734
  if (this.bailIfStopped())
515
735
  return;
516
- if (publicUrl !== row.public_url) {
736
+ // A pending row has never been registered with ClawChat (public_url is null),
737
+ // so this condition also covers "register it for the first time".
738
+ if (publicUrl !== row.public_url || appId !== row.app_id) {
517
739
  await deps.registerApp({
518
- name: row.app_name, appId: row.app_id, url: publicUrl,
740
+ name: row.app_name, appId, url: publicUrl,
519
741
  });
520
742
  }
521
743
  deps.store.upsertLivewareSample({
522
744
  platform: deps.platform, accountId: deps.accountId,
523
- appId: row.app_id, appName: row.app_name, port: server.port,
745
+ appId, appName: row.app_name, port: server.port,
524
746
  publicUrl, sampleVersion: version, status: "active",
525
747
  });
526
748
  this.watchChildren();
@@ -553,7 +775,15 @@ export class LivewareSampleSupervisor {
553
775
  });
554
776
  if (!row || row.status === "disabled")
555
777
  return;
556
- void this.relaunch(row).catch((err) => {
778
+ // This relaunch does NOT go through startAttempt, so it must do that
779
+ // method's launchInFlight bookkeeping itself: the timer has already been
780
+ // dropped from `timers` and children are only assigned a few awaits in, so
781
+ // without this isIdle() reports idle for the whole early part of a
782
+ // crash-restart and a concurrent adoptDeps kick would run a SECOND launch
783
+ // flow — the duplicate registration the single-supervisor rule prevents.
784
+ this.launchInFlight = true;
785
+ void this.relaunch(row)
786
+ .catch((err) => {
557
787
  // A partially-completed relaunch may have spawned children before
558
788
  // failing — never leave them running unwatched.
559
789
  this.killChildren();
@@ -562,7 +792,8 @@ export class LivewareSampleSupervisor {
562
792
  platform: this.deps.platform, accountId: this.deps.accountId,
563
793
  status: "failed", lastError: String(err),
564
794
  });
565
- });
795
+ })
796
+ .finally(() => { this.launchInFlight = false; });
566
797
  }, delay);
567
798
  };
568
799
  for (const child of [this.serverChild, this.tunnelChild]) {
@@ -8,10 +8,12 @@
8
8
  * anchored, wrapping decorations (`<>`, `[]`, `{}`, `/`, backticks, `*`) are
9
9
  * tolerated for free and must not be written into the pattern itself.
10
10
  *
11
- * RULE B — the host's bare markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
12
- * `NO REPLY`), matched as a WHOLE STRING ONLY. These are ordinary English
13
- * words; substring-matching them would swallow prose such as "there is no reply
14
- * from the server".
11
+ * RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
12
+ * `NO REPLY`), matched as a WHOLE STRING ONLY. OpenClaw itself has no such
13
+ * convention; these are carried because other agent runtimes define them, so a
14
+ * model may fall back to one instead of the `clawchat:` form. They are ordinary
15
+ * English words — substring-matching them would swallow prose such as "there is
16
+ * no reply from the server".
15
17
  *
16
18
  * This module MUST stay a literal mirror of the Hermes plugin's equivalent
17
19
  * module. When one side changes, change the other in the same breath.
@@ -1,5 +1,6 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { EVENT, MessageSendError } from "./protocol-types.js";
2
+ import { EVENT, MessageSendError, TERMINAL_CHAT_CODES } from "./protocol-types.js";
3
+ import { CHAT_ID_PREFIX, isValidChatId } from "./ws-client.js";
3
4
  import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
4
5
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
5
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
@@ -115,14 +116,71 @@ function getAlignedMessageErrorTracker(client, account, log) {
115
116
  export function setAlignedOutboundLogContext(client, context) {
116
117
  alignedOutboundContexts.set(client, context);
117
118
  }
119
+ /**
120
+ * The single outbound gate predicate. Narrow by design: only a SERVER-confirmed
121
+ * rejection blocks, never a `conversation.dissolved` inference, and never the
122
+ * owner's direct chat (the client applies that exemption inside isSendBlocked).
123
+ * Optional-call form because outbound tests hand-roll a client mock.
124
+ */
125
+ function isOutboundBlocked(client, chatId) {
126
+ if (!chatId)
127
+ return false;
128
+ return client.isSendBlocked?.(chatId) === true;
129
+ }
118
130
  export function flushAlignedOutboundQueue(client) {
119
131
  const queue = alignedOutboundQueues.get(client);
120
- queue?.flush((wire) => client.sendWire(wire));
132
+ // Re-check the gate at write time: this replay path (called on every
133
+ // `connected` edge) does not re-enter sendAlignedAckableEnvelope.
134
+ queue?.flush((wire) => client.sendWire(wire), { isBlocked: (item) => isOutboundBlocked(client, item.chatId) });
121
135
  }
122
136
  export function getAlignedOutboundQueueSize(client) {
123
137
  return alignedOutboundQueues.get(client)?.snapshot().length ?? 0;
124
138
  }
125
139
  async function sendAlignedAckableEnvelope(params) {
140
+ // Static addressing check, before any server state is consulted: this one is
141
+ // decidable locally and is never revocable, so it is not a "gate" that can
142
+ // later clear. msghub refuses such a frame with `invalid conversation id`.
143
+ if (!isValidChatId(params.chatId)) {
144
+ params.log?.info?.(formatWsLog({
145
+ event: "send_blocked",
146
+ accountId: params.account.accountId,
147
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
148
+ attempt: 1,
149
+ reconnectCount: 0,
150
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
151
+ }),
152
+ action: "drop",
153
+ fields: [
154
+ ["event_name", params.eventName],
155
+ ["chat_id", params.chatId],
156
+ ["reason", "invalid_chat_id"],
157
+ ],
158
+ }));
159
+ throw new MessageSendError("-", "invalid_chat_id", `"${params.chatId}" is not a ClawChat conversation id (expected ${CHAT_ID_PREFIX}…); refusing to send`, params.chatId);
160
+ }
161
+ // The server has already rejected this conversation with a terminal code and
162
+ // that rejection has not expired. Stop here: every further frame would be
163
+ // refused the same way. Gated on the narrow server-confirmed tier ONLY — a
164
+ // `conversation.dissolved` signal must never reach this branch, and the
165
+ // owner's direct chat is exempt (see ClawChatClient.isSendBlocked).
166
+ if (isOutboundBlocked(params.client, params.chatId)) {
167
+ params.log?.info?.(formatWsLog({
168
+ event: "send_blocked",
169
+ accountId: params.account.accountId,
170
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
171
+ attempt: 1,
172
+ reconnectCount: 0,
173
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
174
+ }),
175
+ action: "drop",
176
+ fields: [
177
+ ["event_name", params.eventName],
178
+ ["chat_id", params.chatId],
179
+ ["reason", "chat_rejected_by_server"],
180
+ ],
181
+ }));
182
+ throw new MessageSendError("-", "chat_rejected_local", "conversation was rejected by the server; refusing to send", params.chatId);
183
+ }
126
184
  const traceId = params.client.nextTraceId();
127
185
  const env = {
128
186
  version: "2",
@@ -206,6 +264,15 @@ async function sendAlignedAckableEnvelope(params) {
206
264
  : typeof payload.message === "string" && payload.message
207
265
  ? payload.message
208
266
  : "message send failed";
267
+ // A terminal refusal proves the conversation is unreachable for this
268
+ // sender right now. Record it on the SERVER tier so the gates below
269
+ // stop subsequent frames — this is fact, not the inference that
270
+ // `conversation.dissolved` gives us. It expires; see the client.
271
+ if (TERMINAL_CHAT_CODES.has(code)
272
+ && typeof ack.chat_id === "string"
273
+ && ack.chat_id) {
274
+ params.client.markChatDead?.(ack.chat_id, "server");
275
+ }
209
276
  fail(new MessageSendError(traceId, code, hint, ack.chat_id));
210
277
  return;
211
278
  }
@@ -242,6 +309,9 @@ async function sendAlignedAckableEnvelope(params) {
242
309
  onDrop: () => {
243
310
  fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
244
311
  },
312
+ onBlock: () => {
313
+ fail(new MessageSendError("-", "chat_rejected_local", "conversation was rejected by the server while this frame was queued", params.chatId));
314
+ },
245
315
  };
246
316
  function isTerminalClose(close) {
247
317
  return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
@@ -280,7 +350,7 @@ async function sendAlignedAckableEnvelope(params) {
280
350
  }
281
351
  try {
282
352
  queue.enqueue(item);
283
- queue.flush((queuedWire) => params.client.sendWire(queuedWire));
353
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire), { isBlocked: (queued) => isOutboundBlocked(params.client, queued.chatId) });
284
354
  }
285
355
  catch {
286
356
  // The queue keeps the failed frame at the head for reconnect retry, so
@@ -494,6 +564,14 @@ export async function sendOpenclawClawlingMedia(params) {
494
564
  * path used for message.send/reply does not apply). Matches the mobile client.
495
565
  */
496
566
  export function sendOpenclawClawlingReaction(params) {
567
+ if (!isValidChatId(params.chatId)) {
568
+ params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`);
569
+ return;
570
+ }
571
+ if (isOutboundBlocked(params.client, params.chatId)) {
572
+ params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`);
573
+ return;
574
+ }
497
575
  const payload = {
498
576
  target_message_id: params.targetMessageId,
499
577
  emoji: params.emoji,