@clawling/clawchat-plugin-openclaw 2026.6.30-2 → 2026.6.30-4

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.
@@ -296,6 +296,12 @@ export function createOpenclawClawlingReplyDispatcher(options) {
296
296
  let typingActive = false;
297
297
  let terminalReplySuppressed = false;
298
298
  let finalDeliverySeen = false;
299
+ // Count of outbound messages that were actually delivered to the target
300
+ // (a real, successful send — not a suppressed/duplicate/failed one). This is
301
+ // the reliable "did anything actually reach the user?" signal; host-side
302
+ // `counts.block`/`counts.final` only reflect what the agent emitted, not
303
+ // whether the send succeeded (a cancelled send still counts as a block).
304
+ let outboundDeliveredCount = 0;
299
305
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
300
306
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
301
307
  const terminalSendScopeId = options.terminalSendScopeId ?? null;
@@ -502,6 +508,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
502
508
  log,
503
509
  });
504
510
  recordOutboundAck("message", messageId, result);
511
+ if (result)
512
+ outboundDeliveredCount += 1;
505
513
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`);
506
514
  openclawLlmContextDebug.writeSnapshot({
507
515
  visibility: "host_event",
@@ -676,6 +684,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
676
684
  });
677
685
  return {
678
686
  dispatcher: base.dispatcher,
687
+ getOutboundDeliveredCount: () => outboundDeliveredCount,
679
688
  replyOptions: {
680
689
  ...base.replyOptions,
681
690
  sourceReplyDeliveryMode: "automatic",
@@ -33,6 +33,34 @@ export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
33
33
  const activeClients = new Map();
34
34
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
35
35
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
36
+ // Activation-bootstrap retry on the live connection. The bootstrap greeting is
37
+ // otherwise only (re)dispatched on a fresh `connected` edge, so if its send is
38
+ // cancelled mid-turn (e.g. a proactive token-refresh reconnect closed the
39
+ // transport) or a reconnect's claim no-op'd while a stale in-flight dispatch
40
+ // still held the claim, the greeting would stay pending forever on a now-stable
41
+ // connection. These bound an in-connection retry that re-attempts until the
42
+ // greeting lands or the per-connection budget is spent (then it falls back to
43
+ // the next reconnect, as before).
44
+ const ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS = 5_000;
45
+ const ACTIVATION_BOOTSTRAP_MAX_RETRIES = 6;
46
+ // Count of in-flight activation-bootstrap dispatches per account, shared across
47
+ // gateway re-enters so a fresh connection can tell "nothing pending" (don't
48
+ // retry) apart from "a prior dispatch still holds the claim" (retry once it
49
+ // releases).
50
+ const activationBootstrapInFlight = new Map();
51
+ function incrementActivationBootstrapInFlight(accountId) {
52
+ activationBootstrapInFlight.set(accountId, (activationBootstrapInFlight.get(accountId) ?? 0) + 1);
53
+ }
54
+ function decrementActivationBootstrapInFlight(accountId) {
55
+ const next = (activationBootstrapInFlight.get(accountId) ?? 0) - 1;
56
+ if (next <= 0)
57
+ activationBootstrapInFlight.delete(accountId);
58
+ else
59
+ activationBootstrapInFlight.set(accountId, next);
60
+ }
61
+ function activationBootstrapInFlightCount(accountId) {
62
+ return activationBootstrapInFlight.get(accountId) ?? 0;
63
+ }
36
64
  // Upper bound (ms) on how long group ingest waits for the FIRST group-settings
37
65
  // pull to settle before proceeding anyway. Best-effort governance must never
38
66
  // permanently disable group traffic, so a stalled initial GET (endpoint accepts
@@ -1446,10 +1474,38 @@ export async function startOpenclawClawlingGateway(params) {
1446
1474
  };
1447
1475
  let dispatchActivationBootstrap = async () => { };
1448
1476
  // Set by dispatchTurnToAgent when it dispatches the synthetic
1449
- // `clawchat-bootstrap` turn: true only if the agent actually produced a final
1450
- // outbound message (the greeting). The bootstrap dispatcher reads it to decide
1451
- // whether to mark the bootstrap sent or leave it pending for a later retry.
1452
- let bootstrapProducedFinal = false;
1477
+ // `clawchat-bootstrap` turn: true only if the greeting was actually delivered
1478
+ // (a real successful outbound send — block or final). The bootstrap dispatcher
1479
+ // reads it to decide whether to mark the bootstrap sent or leave it pending for
1480
+ // a later retry.
1481
+ let bootstrapGreetingDelivered = false;
1482
+ // Per-connection retry for a pending activation bootstrap (see the module-level
1483
+ // constants). Re-attempts on the live connection while `wsReady`, bounded by
1484
+ // ACTIVATION_BOOTSTRAP_MAX_RETRIES; the budget resets on each fresh connect.
1485
+ let bootstrapRetryTimer;
1486
+ let bootstrapRetryAttempts = 0;
1487
+ const bootstrapRetryDelayMs = Math.max(1, params.activationBootstrapRetryDelayMs ?? ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS);
1488
+ const clearBootstrapRetry = () => {
1489
+ if (bootstrapRetryTimer !== undefined) {
1490
+ clearTimeout(bootstrapRetryTimer);
1491
+ bootstrapRetryTimer = undefined;
1492
+ }
1493
+ };
1494
+ const armBootstrapRetry = () => {
1495
+ if (bootstrapRetryTimer !== undefined)
1496
+ return;
1497
+ if (!wsReady)
1498
+ return;
1499
+ if (bootstrapRetryAttempts >= ACTIVATION_BOOTSTRAP_MAX_RETRIES)
1500
+ return;
1501
+ bootstrapRetryTimer = setTimeout(() => {
1502
+ bootstrapRetryTimer = undefined;
1503
+ if (!wsReady)
1504
+ return;
1505
+ bootstrapRetryAttempts += 1;
1506
+ void dispatchActivationBootstrap();
1507
+ }, bootstrapRetryDelayMs);
1508
+ };
1453
1509
  client.on("state", ({ from, to }) => {
1454
1510
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw state ${from} -> ${to}`);
1455
1511
  wsReady = to === "connected";
@@ -1522,6 +1578,9 @@ export async function startOpenclawClawlingGateway(params) {
1522
1578
  // The queue keeps the failed frame at the head and will retry after the next reconnect.
1523
1579
  }
1524
1580
  void refreshConversationCacheAfterReady();
1581
+ // Fresh connection → fresh retry budget; cancel any stale timer first.
1582
+ bootstrapRetryAttempts = 0;
1583
+ clearBootstrapRetry();
1525
1584
  void dispatchActivationBootstrap();
1526
1585
  // Re-pull per-group agent settings on every successful (re)connect so the
1527
1586
  // cache stays current after a network interruption or token refresh.
@@ -1533,6 +1592,8 @@ export async function startOpenclawClawlingGateway(params) {
1533
1592
  }
1534
1593
  else if (to === "disconnected") {
1535
1594
  reconnectTracker.markClosed();
1595
+ // Stop the bootstrap retry while down; it re-arms on the next ready.
1596
+ clearBootstrapRetry();
1536
1597
  // §A.1 — clear the proactive timer on disconnect; it re-arms on the next
1537
1598
  // ready, or the gateway re-enter arms a fresh one.
1538
1599
  if (!reconnectWithRefreshedToken)
@@ -2106,7 +2167,7 @@ export async function startOpenclawClawlingGateway(params) {
2106
2167
  chatType: turn.peer.kind,
2107
2168
  agentId: route.agentId,
2108
2169
  });
2109
- const { dispatcher, replyOptions, markDispatchIdle } = createOpenclawClawlingReplyDispatcher({
2170
+ const { dispatcher, replyOptions, markDispatchIdle, getOutboundDeliveredCount } = createOpenclawClawlingReplyDispatcher({
2110
2171
  cfg,
2111
2172
  runtime,
2112
2173
  account,
@@ -2142,10 +2203,14 @@ export async function startOpenclawClawlingGateway(params) {
2142
2203
  const counts = dispatchResult?.counts ?? {};
2143
2204
  const queuedFinal = Boolean(dispatchResult?.queuedFinal);
2144
2205
  if (turn.senderId === "clawchat-bootstrap") {
2145
- // Did the agent actually emit a greeting? A final outbound message
2146
- // means yes; block/tool-only (or nothing) means the greeting never
2147
- // reached the user, so the bootstrap dispatcher will keep it pending.
2148
- bootstrapProducedFinal = queuedFinal || (counts.final ?? 0) > 0;
2206
+ // Did the greeting actually reach the user? The reliable signal is a
2207
+ // real, successful outbound send (the agent may deliver the greeting
2208
+ // as a `block`, not a `final`, and a cancelled send still shows up in
2209
+ // host `counts.block`). Treat a queued/counted final as delivered too,
2210
+ // to cover deferred/human-delay finals not yet flushed at this point.
2211
+ const deliveredOutbound = getOutboundDeliveredCount?.() ?? 0;
2212
+ bootstrapGreetingDelivered =
2213
+ deliveredOutbound > 0 || queuedFinal || (counts.final ?? 0) > 0;
2149
2214
  }
2150
2215
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`);
2151
2216
  if (!queuedFinal && Object.values(counts).every((n) => !n)) {
@@ -2389,6 +2454,7 @@ export async function startOpenclawClawlingGateway(params) {
2389
2454
  if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent)
2390
2455
  return;
2391
2456
  let bootstrap;
2457
+ let claimedInFlight = false;
2392
2458
  const releaseBootstrap = () => {
2393
2459
  if (!bootstrap || !store.releaseActivationBootstrapClaim)
2394
2460
  return;
@@ -2401,23 +2467,34 @@ export async function startOpenclawClawlingGateway(params) {
2401
2467
  };
2402
2468
  try {
2403
2469
  bootstrap = recordConnection("activation bootstrap claim", () => store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }));
2404
- if (!bootstrap)
2470
+ if (!bootstrap) {
2471
+ // Nothing claimable right now. If another dispatch (e.g. a pre-reconnect
2472
+ // one that was about to fail) still holds the claim, keep retrying on
2473
+ // this live connection so the greeting isn't stranded once it releases.
2474
+ if (activationBootstrapInFlightCount(accountId) > 0)
2475
+ armBootstrapRetry();
2405
2476
  return;
2477
+ }
2406
2478
  const claimedBootstrap = bootstrap;
2407
- bootstrapProducedFinal = false;
2479
+ claimedInFlight = true;
2480
+ incrementActivationBootstrapInFlight(accountId);
2481
+ bootstrapGreetingDelivered = false;
2408
2482
  const result = await handleInboundEnvelope(buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }));
2409
2483
  if (result !== "submitted") {
2410
2484
  releaseBootstrap();
2485
+ armBootstrapRetry();
2411
2486
  return;
2412
2487
  }
2413
- // Only mark the bootstrap done once the agent has actually sent a greeting
2414
- // (a final outbound message). If the turn was handed off but produced no
2415
- // final reply — e.g. the agent went off to write a file/note instead of
2416
- // replying — leave the bootstrap pending (release the claim) so the next
2417
- // ready re-dispatches it, rather than silently consuming it forever.
2418
- if (!bootstrapProducedFinal) {
2419
- log?.info?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but produced no final reply; leaving pending for retry`);
2488
+ // Only mark the bootstrap done once the greeting was actually delivered
2489
+ // (a real successful outbound send). If the turn was handed off but nothing
2490
+ // reached the user — e.g. the greeting send was cancelled by a reconnect, or
2491
+ // the agent went off to write a file/note instead of replying — leave the
2492
+ // bootstrap pending (release the claim) and retry on the live connection,
2493
+ // rather than silently consuming it forever or waiting for the next reconnect.
2494
+ if (!bootstrapGreetingDelivered) {
2495
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but greeting was not delivered; leaving pending for retry`);
2420
2496
  releaseBootstrap();
2497
+ armBootstrapRetry();
2421
2498
  return;
2422
2499
  }
2423
2500
  recordConnection("activation bootstrap sent", () => store.markActivationBootstrapSent?.({
@@ -2425,11 +2502,18 @@ export async function startOpenclawClawlingGateway(params) {
2425
2502
  accountId,
2426
2503
  conversationId: claimedBootstrap.conversationId,
2427
2504
  }));
2505
+ // Delivered — stop any pending retry.
2506
+ clearBootstrapRetry();
2428
2507
  }
2429
2508
  catch (err) {
2430
2509
  releaseBootstrap();
2510
+ armBootstrapRetry();
2431
2511
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
2432
2512
  }
2513
+ finally {
2514
+ if (claimedInFlight)
2515
+ decrementActivationBootstrapInFlight(accountId);
2516
+ }
2433
2517
  };
2434
2518
  client.on("message", (env) => {
2435
2519
  void (async () => {
@@ -2541,6 +2625,7 @@ export async function startOpenclawClawlingGateway(params) {
2541
2625
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
2542
2626
  activeClients.delete(accountId);
2543
2627
  closingForAbort = true;
2628
+ clearBootstrapRetry();
2544
2629
  // §A.1 — stop the proactive refresh timer on shutdown.
2545
2630
  refreshManager.stop();
2546
2631
  groupCoalescer.cancelAll();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.6.30-2",
3
+ "version": "2026.6.30-4",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -375,6 +375,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
375
375
  dispatcher: TypedReplyDispatcherResult["dispatcher"];
376
376
  replyOptions: ClawChatReplyOptions;
377
377
  markDispatchIdle: TypedReplyDispatcherResult["markDispatchIdle"];
378
+ /** Number of outbound messages actually delivered to the target so far. */
379
+ getOutboundDeliveredCount: () => number;
378
380
  } {
379
381
  const {
380
382
  cfg,
@@ -430,6 +432,12 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
430
432
  let typingActive = false;
431
433
  let terminalReplySuppressed = false;
432
434
  let finalDeliverySeen = false;
435
+ // Count of outbound messages that were actually delivered to the target
436
+ // (a real, successful send — not a suppressed/duplicate/failed one). This is
437
+ // the reliable "did anything actually reach the user?" signal; host-side
438
+ // `counts.block`/`counts.final` only reflect what the agent emitted, not
439
+ // whether the send succeeded (a cancelled send still counts as a block).
440
+ let outboundDeliveredCount = 0;
433
441
 
434
442
  const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
435
443
  const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
@@ -646,6 +654,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
646
654
  log,
647
655
  });
648
656
  recordOutboundAck("message", messageId, result);
657
+ if (result) outboundDeliveredCount += 1;
649
658
  log?.info?.(
650
659
  `[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`,
651
660
  );
@@ -835,6 +844,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
835
844
 
836
845
  return {
837
846
  dispatcher: base.dispatcher,
847
+ getOutboundDeliveredCount: () => outboundDeliveredCount,
838
848
  replyOptions: {
839
849
  ...base.replyOptions,
840
850
  sourceReplyDeliveryMode: "automatic",
package/src/runtime.ts CHANGED
@@ -120,6 +120,32 @@ export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
120
120
  const activeClients = new Map<string, ClawlingChatClient>();
121
121
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
122
122
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
123
+ // Activation-bootstrap retry on the live connection. The bootstrap greeting is
124
+ // otherwise only (re)dispatched on a fresh `connected` edge, so if its send is
125
+ // cancelled mid-turn (e.g. a proactive token-refresh reconnect closed the
126
+ // transport) or a reconnect's claim no-op'd while a stale in-flight dispatch
127
+ // still held the claim, the greeting would stay pending forever on a now-stable
128
+ // connection. These bound an in-connection retry that re-attempts until the
129
+ // greeting lands or the per-connection budget is spent (then it falls back to
130
+ // the next reconnect, as before).
131
+ const ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS = 5_000;
132
+ const ACTIVATION_BOOTSTRAP_MAX_RETRIES = 6;
133
+ // Count of in-flight activation-bootstrap dispatches per account, shared across
134
+ // gateway re-enters so a fresh connection can tell "nothing pending" (don't
135
+ // retry) apart from "a prior dispatch still holds the claim" (retry once it
136
+ // releases).
137
+ const activationBootstrapInFlight = new Map<string, number>();
138
+ function incrementActivationBootstrapInFlight(accountId: string): void {
139
+ activationBootstrapInFlight.set(accountId, (activationBootstrapInFlight.get(accountId) ?? 0) + 1);
140
+ }
141
+ function decrementActivationBootstrapInFlight(accountId: string): void {
142
+ const next = (activationBootstrapInFlight.get(accountId) ?? 0) - 1;
143
+ if (next <= 0) activationBootstrapInFlight.delete(accountId);
144
+ else activationBootstrapInFlight.set(accountId, next);
145
+ }
146
+ function activationBootstrapInFlightCount(accountId: string): number {
147
+ return activationBootstrapInFlight.get(accountId) ?? 0;
148
+ }
123
149
  // Upper bound (ms) on how long group ingest waits for the FIRST group-settings
124
150
  // pull to settle before proceeding anyway. Best-effort governance must never
125
151
  // permanently disable group traffic, so a stalled initial GET (endpoint accepts
@@ -543,6 +569,8 @@ export interface StartGatewayParams {
543
569
  backoffTimer?: (cb: () => void, ms: number) => void;
544
570
  /** Test hook only — fixed transport-backoff delay (ms) for re-enter. */
545
571
  transportBackoffDelayMs?: number;
572
+ /** Test hook only — retry delay (ms) for re-dispatching a pending activation bootstrap. */
573
+ activationBootstrapRetryDelayMs?: number;
546
574
  }
547
575
 
548
576
  /**
@@ -1822,10 +1850,37 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1822
1850
  };
1823
1851
  let dispatchActivationBootstrap: () => Promise<void> = async () => {};
1824
1852
  // Set by dispatchTurnToAgent when it dispatches the synthetic
1825
- // `clawchat-bootstrap` turn: true only if the agent actually produced a final
1826
- // outbound message (the greeting). The bootstrap dispatcher reads it to decide
1827
- // whether to mark the bootstrap sent or leave it pending for a later retry.
1828
- let bootstrapProducedFinal = false;
1853
+ // `clawchat-bootstrap` turn: true only if the greeting was actually delivered
1854
+ // (a real successful outbound send — block or final). The bootstrap dispatcher
1855
+ // reads it to decide whether to mark the bootstrap sent or leave it pending for
1856
+ // a later retry.
1857
+ let bootstrapGreetingDelivered = false;
1858
+ // Per-connection retry for a pending activation bootstrap (see the module-level
1859
+ // constants). Re-attempts on the live connection while `wsReady`, bounded by
1860
+ // ACTIVATION_BOOTSTRAP_MAX_RETRIES; the budget resets on each fresh connect.
1861
+ let bootstrapRetryTimer: ReturnType<typeof setTimeout> | undefined;
1862
+ let bootstrapRetryAttempts = 0;
1863
+ const bootstrapRetryDelayMs = Math.max(
1864
+ 1,
1865
+ params.activationBootstrapRetryDelayMs ?? ACTIVATION_BOOTSTRAP_RETRY_DELAY_MS,
1866
+ );
1867
+ const clearBootstrapRetry = () => {
1868
+ if (bootstrapRetryTimer !== undefined) {
1869
+ clearTimeout(bootstrapRetryTimer);
1870
+ bootstrapRetryTimer = undefined;
1871
+ }
1872
+ };
1873
+ const armBootstrapRetry = () => {
1874
+ if (bootstrapRetryTimer !== undefined) return;
1875
+ if (!wsReady) return;
1876
+ if (bootstrapRetryAttempts >= ACTIVATION_BOOTSTRAP_MAX_RETRIES) return;
1877
+ bootstrapRetryTimer = setTimeout(() => {
1878
+ bootstrapRetryTimer = undefined;
1879
+ if (!wsReady) return;
1880
+ bootstrapRetryAttempts += 1;
1881
+ void dispatchActivationBootstrap();
1882
+ }, bootstrapRetryDelayMs);
1883
+ };
1829
1884
 
1830
1885
  client.on("state", ({ from, to }) => {
1831
1886
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw state ${from} -> ${to}`);
@@ -1900,6 +1955,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1900
1955
  // The queue keeps the failed frame at the head and will retry after the next reconnect.
1901
1956
  }
1902
1957
  void refreshConversationCacheAfterReady();
1958
+ // Fresh connection → fresh retry budget; cancel any stale timer first.
1959
+ bootstrapRetryAttempts = 0;
1960
+ clearBootstrapRetry();
1903
1961
  void dispatchActivationBootstrap();
1904
1962
  // Re-pull per-group agent settings on every successful (re)connect so the
1905
1963
  // cache stays current after a network interruption or token refresh.
@@ -1910,6 +1968,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1910
1968
  refreshManager.armProactiveTimer(activatedAtMs);
1911
1969
  } else if (to === "disconnected") {
1912
1970
  reconnectTracker.markClosed();
1971
+ // Stop the bootstrap retry while down; it re-arms on the next ready.
1972
+ clearBootstrapRetry();
1913
1973
  // §A.1 — clear the proactive timer on disconnect; it re-arms on the next
1914
1974
  // ready, or the gateway re-enter arms a fresh one.
1915
1975
  if (!reconnectWithRefreshedToken) refreshManager.disarmProactiveTimer();
@@ -2544,7 +2604,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2544
2604
  chatType: turn.peer.kind,
2545
2605
  agentId: route.agentId,
2546
2606
  });
2547
- const { dispatcher, replyOptions, markDispatchIdle } =
2607
+ const { dispatcher, replyOptions, markDispatchIdle, getOutboundDeliveredCount } =
2548
2608
  createOpenclawClawlingReplyDispatcher({
2549
2609
  cfg,
2550
2610
  runtime,
@@ -2590,10 +2650,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2590
2650
  (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
2591
2651
  );
2592
2652
  if (turn.senderId === "clawchat-bootstrap") {
2593
- // Did the agent actually emit a greeting? A final outbound message
2594
- // means yes; block/tool-only (or nothing) means the greeting never
2595
- // reached the user, so the bootstrap dispatcher will keep it pending.
2596
- bootstrapProducedFinal = queuedFinal || (counts.final ?? 0) > 0;
2653
+ // Did the greeting actually reach the user? The reliable signal is a
2654
+ // real, successful outbound send (the agent may deliver the greeting
2655
+ // as a `block`, not a `final`, and a cancelled send still shows up in
2656
+ // host `counts.block`). Treat a queued/counted final as delivered too,
2657
+ // to cover deferred/human-delay finals not yet flushed at this point.
2658
+ const deliveredOutbound = getOutboundDeliveredCount?.() ?? 0;
2659
+ bootstrapGreetingDelivered =
2660
+ deliveredOutbound > 0 || queuedFinal || (counts.final ?? 0) > 0;
2597
2661
  }
2598
2662
  log?.info?.(
2599
2663
  `[${accountId}] clawchat-plugin-openclaw dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
@@ -2876,6 +2940,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2876
2940
  dispatchActivationBootstrap = async (): Promise<void> => {
2877
2941
  if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent) return;
2878
2942
  let bootstrap: { conversationId: string } | null | undefined;
2943
+ let claimedInFlight = false;
2879
2944
  const releaseBootstrap = () => {
2880
2945
  if (!bootstrap || !store.releaseActivationBootstrapClaim) return;
2881
2946
  const claimedBootstrap = bootstrap;
@@ -2891,26 +2956,37 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2891
2956
  bootstrap = recordConnection("activation bootstrap claim", () =>
2892
2957
  store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }),
2893
2958
  );
2894
- if (!bootstrap) return;
2959
+ if (!bootstrap) {
2960
+ // Nothing claimable right now. If another dispatch (e.g. a pre-reconnect
2961
+ // one that was about to fail) still holds the claim, keep retrying on
2962
+ // this live connection so the greeting isn't stranded once it releases.
2963
+ if (activationBootstrapInFlightCount(accountId) > 0) armBootstrapRetry();
2964
+ return;
2965
+ }
2895
2966
  const claimedBootstrap = bootstrap;
2896
- bootstrapProducedFinal = false;
2967
+ claimedInFlight = true;
2968
+ incrementActivationBootstrapInFlight(accountId);
2969
+ bootstrapGreetingDelivered = false;
2897
2970
  const result = await handleInboundEnvelope(
2898
2971
  buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }),
2899
2972
  );
2900
2973
  if (result !== "submitted") {
2901
2974
  releaseBootstrap();
2975
+ armBootstrapRetry();
2902
2976
  return;
2903
2977
  }
2904
- // Only mark the bootstrap done once the agent has actually sent a greeting
2905
- // (a final outbound message). If the turn was handed off but produced no
2906
- // final reply — e.g. the agent went off to write a file/note instead of
2907
- // replying — leave the bootstrap pending (release the claim) so the next
2908
- // ready re-dispatches it, rather than silently consuming it forever.
2909
- if (!bootstrapProducedFinal) {
2978
+ // Only mark the bootstrap done once the greeting was actually delivered
2979
+ // (a real successful outbound send). If the turn was handed off but nothing
2980
+ // reached the user — e.g. the greeting send was cancelled by a reconnect, or
2981
+ // the agent went off to write a file/note instead of replying — leave the
2982
+ // bootstrap pending (release the claim) and retry on the live connection,
2983
+ // rather than silently consuming it forever or waiting for the next reconnect.
2984
+ if (!bootstrapGreetingDelivered) {
2910
2985
  log?.info?.(
2911
- `[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but produced no final reply; leaving pending for retry`,
2986
+ `[${accountId}] clawchat-plugin-openclaw activation bootstrap dispatched but greeting was not delivered; leaving pending for retry`,
2912
2987
  );
2913
2988
  releaseBootstrap();
2989
+ armBootstrapRetry();
2914
2990
  return;
2915
2991
  }
2916
2992
  recordConnection("activation bootstrap sent", () =>
@@ -2920,11 +2996,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2920
2996
  conversationId: claimedBootstrap.conversationId,
2921
2997
  }),
2922
2998
  );
2999
+ // Delivered — stop any pending retry.
3000
+ clearBootstrapRetry();
2923
3001
  } catch (err) {
2924
3002
  releaseBootstrap();
3003
+ armBootstrapRetry();
2925
3004
  log?.error?.(
2926
3005
  `[${accountId}] clawchat-plugin-openclaw activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`,
2927
3006
  );
3007
+ } finally {
3008
+ if (claimedInFlight) decrementActivationBootstrapInFlight(accountId);
2928
3009
  }
2929
3010
  };
2930
3011
 
@@ -3049,6 +3130,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3049
3130
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
3050
3131
  activeClients.delete(accountId);
3051
3132
  closingForAbort = true;
3133
+ clearBootstrapRetry();
3052
3134
  // §A.1 — stop the proactive refresh timer on shutdown.
3053
3135
  refreshManager.stop();
3054
3136
  groupCoalescer.cancelAll();