@clawling/clawchat-plugin-openclaw 2026.10.11-2 → 2026.10.13-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.
package/src/runtime.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  type LivewareSampleSupervisorDeps,
37
37
  } from "./liveware-sample.ts";
38
38
  import { ClawlingApiError } from "./api-types.ts";
39
- import { RefreshManager } from "./refresh-manager.ts";
39
+ import { RefreshManager, fingerprintRefreshToken, type RevokedRefreshMarker } from "./refresh-manager.ts";
40
40
  import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
41
41
  import {
42
42
  runOpenclawClawlingLogin,
@@ -143,6 +143,9 @@ type RuntimeConnectionStore = Pick<
143
143
  | "upsertActivation"
144
144
  | "rotateActivationTokens"
145
145
  | "clearActivationCredentials"
146
+ | "getRevokedRefreshMarker"
147
+ | "setRevokedRefreshMarker"
148
+ | "clearRevokedRefreshMarker"
146
149
  | "insertMessage"
147
150
  | "claimMessageOnce"
148
151
  | "markMessageAcknowledged"
@@ -279,20 +282,27 @@ export function classifyHelloFailReason(
279
282
  * carries no `token` of its own (i.e. the access token itself came from
280
283
  * `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
281
284
  * token is never paired with a stray refresh token left in the environment.
285
+ * A revoked marker also excludes a matching refresh fingerprint from either source.
282
286
  */
283
287
  function readConfigRefreshToken(
284
288
  cfg: OpenClawConfig,
285
289
  accountId: string,
290
+ revokedMarker?: RevokedRefreshMarker | null,
286
291
  env: NodeJS.ProcessEnv = process.env,
287
292
  ): string | null {
288
293
  const section = readOpenclawClawlingAccountSection(cfg, accountId);
289
294
  const refreshToken = section.refreshToken;
290
- if (typeof refreshToken === "string" && refreshToken.trim()) return refreshToken.trim();
295
+ const usable = (token: unknown): string | null => {
296
+ const value = typeof token === "string" ? token.trim() : "";
297
+ return value && (!revokedMarker || fingerprintRefreshToken(value) !== revokedMarker.fingerprint)
298
+ ? value : null;
299
+ };
300
+ if (typeof refreshToken === "string" && refreshToken.trim()) return usable(refreshToken);
291
301
  if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID) return null;
292
302
  const configToken = section.token;
293
303
  if (typeof configToken === "string" && configToken.trim()) return null;
294
304
  const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
295
- return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
305
+ return usable(envRefreshToken);
296
306
  }
297
307
 
298
308
  function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
@@ -367,6 +377,24 @@ function isOpenClawConfirmSlashCommand(rawBody: string): boolean {
367
377
  return Boolean(name && OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name));
368
378
  }
369
379
 
380
+ // Group commands that start an agent turn (OpenClaw 2026.9.8 command
381
+ // handlers): their output is the conversation itself, so it stays in the group
382
+ // with no ✅. `/new <text>` and `/reset <text>` run the text as a prompt.
383
+ const AGENT_TURN_GROUP_COMMANDS = new Set(["btw", "dashboard", "goal", "learn", "loop", "steer", "tell"]);
384
+
385
+ function opensAgentTurn(commandText: string): boolean {
386
+ const name = parseSlashCommandName(commandText);
387
+ if (!name) return false;
388
+ if (AGENT_TURN_GROUP_COMMANDS.has(name)) return true;
389
+ const hasArgs = /\s\S/.test(commandText.trim());
390
+ return (name === "new" || name === "reset") && hasArgs;
391
+ }
392
+
393
+ /** First line of the owner's direct-chat receipt for a group command. */
394
+ export function ownerCommandReceiptHeader(groupTitle: string, commandText: string): string {
395
+ return `From group "${groupTitle}": ${commandText.trim()}`;
396
+ }
397
+
370
398
  function isOpenClawTextSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
371
399
  return Boolean(parseSlashCommandName(rawBody) && (
372
400
  hasControlCommand(rawBody, cfg)
@@ -637,7 +665,9 @@ export interface StartGatewayParams {
637
665
  * min-interval floor) actually constrain a cross-reconnect refresh loop instead
638
666
  * of resetting on every fresh `RefreshManager`.
639
667
  */
640
- refreshManagerState?: { rejectedToken: string | null; lastAttemptAt: number };
668
+ refreshManagerState?: Parameters<RefreshManager["restoreState"]>[0];
669
+ /** Internal guard when durable marker writes fail; never contains credentials. */
670
+ revokedRefreshMarker?: RevokedRefreshMarker;
641
671
  /**
642
672
  * Internal — the live (possibly rotated) refresh token, carried across a
643
673
  * §D/§B gateway re-enter. A rotation persists config with `afterWrite:{mode:
@@ -972,6 +1002,7 @@ function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<bo
972
1002
  }
973
1003
 
974
1004
  async function waitForActivationCredentials(params: {
1005
+ cfg: OpenClawConfig;
975
1006
  account: ResolvedOpenclawClawlingAccount;
976
1007
  abortSignal: AbortSignal;
977
1008
  getStatus: () => ChannelAccountSnapshot;
@@ -980,16 +1011,23 @@ async function waitForActivationCredentials(params: {
980
1011
  log?: Log;
981
1012
  pollIntervalMs: number;
982
1013
  rejectedActivationToken?: string;
1014
+ revokedRefreshMarker?: RevokedRefreshMarker | null;
983
1015
  }): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
984
1016
  const { abortSignal, getStatus, setStatus, store, log } = params;
985
1017
  const accountId = params.account.accountId;
1018
+ const revoked = params.revokedRefreshMarker;
1019
+ const usable = (credentials: { accessToken: string; refreshToken: string | null }): boolean =>
1020
+ credentials.accessToken !== params.rejectedActivationToken &&
1021
+ (!revoked || (!!credentials.refreshToken &&
1022
+ fingerprintRefreshToken(credentials.refreshToken) !== revoked.fingerprint));
986
1023
  // SQLite-first: activation and token rotation persist to the activations
987
1024
  // row, so a complete row is the durable credential source of truth even
988
1025
  // when channel config/env also carries credentials. Config/env credentials
989
- // only bootstrap the no-row case (the caller seeds a row from them).
1026
+ // bootstrap the no-row case, or replace a revoked pair with a different
1027
+ // refresh fingerprint (the caller seeds a row from the adopted pair).
990
1028
  if (store?.getActivationCredentials) {
991
1029
  const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
992
- if (stored && stored.accessToken !== params.rejectedActivationToken) {
1030
+ if (stored && usable(stored)) {
993
1031
  log?.info?.(
994
1032
  `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
995
1033
  );
@@ -1005,10 +1043,19 @@ async function waitForActivationCredentials(params: {
1005
1043
  };
1006
1044
  }
1007
1045
  }
1008
- if (accountHasConnectCredentials(params.account)) {
1009
- return { account: params.account, source: "configured" };
1046
+ const configuredCredentials = (account: ResolvedOpenclawClawlingAccount) => {
1047
+ const refreshToken = readConfigRefreshToken(params.cfg, accountId, revoked);
1048
+ return accountHasConnectCredentials(account) && usable({ accessToken: account.token, refreshToken })
1049
+ ? { account, source: "configured" as const } : null;
1050
+ };
1051
+ const configured = configuredCredentials(params.account);
1052
+ if (configured) return configured;
1053
+ if (revoked) {
1054
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
1055
+ setStatus({ ...getStatus(), connected: false, configured: false, running: false,
1056
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR });
1010
1057
  }
1011
- if (!store?.getActivationCredentials) {
1058
+ if (!revoked && !store?.getActivationCredentials) {
1012
1059
  const message = "Clawling Chat token is missing and activation database is unavailable";
1013
1060
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${message}`);
1014
1061
  setStatus({
@@ -1024,7 +1071,7 @@ async function waitForActivationCredentials(params: {
1024
1071
  log?.info?.(
1025
1072
  `[${accountId}] clawchat-plugin-openclaw waiting for activation credentials in sqlite`,
1026
1073
  );
1027
- setStatus({
1074
+ if (!revoked) setStatus({
1028
1075
  ...getStatus(),
1029
1076
  connected: false,
1030
1077
  configured: true,
@@ -1033,22 +1080,9 @@ async function waitForActivationCredentials(params: {
1033
1080
  });
1034
1081
 
1035
1082
  while (!abortSignal.aborted) {
1036
- const credentials = store.getActivationCredentials({
1037
- platform: "openclaw",
1038
- accountId,
1039
- });
1040
- if (credentials) {
1041
- if (credentials.accessToken === params.rejectedActivationToken) {
1042
- log?.debug?.(
1043
- `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=rejected_token_unchanged intervalMs=${params.pollIntervalMs}`,
1044
- );
1045
- const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
1046
- if (!shouldContinue) break;
1047
- continue;
1048
- }
1049
- log?.info?.(
1050
- `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
1051
- );
1083
+ const credentials = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
1084
+ if (credentials && usable(credentials)) {
1085
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
1052
1086
  return {
1053
1087
  source: "sqlite",
1054
1088
  account: {
@@ -1060,8 +1094,14 @@ async function waitForActivationCredentials(params: {
1060
1094
  },
1061
1095
  };
1062
1096
  }
1097
+ if (revoked) {
1098
+ // Re-read env credentials while parked so a new pair can recover without
1099
+ // another gateway invocation. Config credentials use the supplied snapshot.
1100
+ const replacement = configuredCredentials(resolveOpenclawClawlingAccount(params.cfg, accountId));
1101
+ if (replacement) return replacement;
1102
+ }
1063
1103
  log?.debug?.(
1064
- `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=waiting intervalMs=${params.pollIntervalMs}`,
1104
+ `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=${credentials ? "rejected_token_unchanged" : "waiting"} intervalMs=${params.pollIntervalMs}`,
1065
1105
  );
1066
1106
  const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
1067
1107
  if (!shouldContinue) break;
@@ -1079,7 +1119,25 @@ async function waitForActivationCredentials(params: {
1079
1119
  let warnedBelowHostFloor = false;
1080
1120
  let loggedHostToolSearch = false;
1081
1121
 
1122
+ /** Re-pair lifecycle owned by one gateway invocation (see performAutoLogout). */
1123
+ type GatewayLifecycle = { rePairWait?: Promise<void> };
1124
+
1082
1125
  export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
1126
+ // Every exit, including an interrupted connect or abort during logout, must
1127
+ // wait for the one re-pair lifecycle this invocation started: settling the
1128
+ // host's startAccount task early would make the host restart the channel.
1129
+ const lifecycle: GatewayLifecycle = {};
1130
+ try {
1131
+ await runOpenclawClawlingGateway(params, lifecycle);
1132
+ } finally {
1133
+ await lifecycle.rePairWait;
1134
+ }
1135
+ }
1136
+
1137
+ async function runOpenclawClawlingGateway(
1138
+ params: StartGatewayParams,
1139
+ lifecycle: GatewayLifecycle,
1140
+ ): Promise<void> {
1083
1141
  const { cfg, abortSignal, setStatus, getStatus, log } = params;
1084
1142
  let account = params.account;
1085
1143
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
@@ -1144,7 +1202,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1144
1202
  log,
1145
1203
  });
1146
1204
  }
1205
+ const revokedRefreshMarker = params.revokedRefreshMarker ??
1206
+ store?.getRevokedRefreshMarker?.({ platform: "openclaw", accountId });
1147
1207
  const activationAccount = await waitForActivationCredentials({
1208
+ cfg,
1148
1209
  account,
1149
1210
  abortSignal,
1150
1211
  getStatus,
@@ -1153,9 +1214,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1153
1214
  log,
1154
1215
  pollIntervalMs: params.activationPollIntervalMs ?? ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS,
1155
1216
  rejectedActivationToken: params.rejectedActivationToken,
1217
+ revokedRefreshMarker,
1156
1218
  });
1157
1219
  if (!activationAccount) return;
1158
1220
  account = activationAccount.account;
1221
+ // Reset in-memory refresh guards only after adopting a different pair.
1222
+ if (revokedRefreshMarker) {
1223
+ params = { ...params, revokedRefreshMarker: undefined, refreshTokenOverride: undefined,
1224
+ refreshManagerState: undefined, rejectedActivationToken: undefined };
1225
+ }
1159
1226
  // Config/env bootstrap seeding: converge on SQLite as the single durable
1160
1227
  // credential store, so token rotation always has a row to update and the
1161
1228
  // next startup resolves SQLite-first to the same credentials.
@@ -1167,7 +1234,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1167
1234
  userId: account.userId,
1168
1235
  ownerUserId: account.ownerUserId,
1169
1236
  accessToken: account.token,
1170
- refreshToken: readConfigRefreshToken(cfg, accountId),
1237
+ refreshToken: readConfigRefreshToken(cfg, accountId, revokedRefreshMarker),
1171
1238
  loginMethod: "config-seed",
1172
1239
  deviceId: CHANNEL_ID,
1173
1240
  });
@@ -1177,6 +1244,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1177
1244
  );
1178
1245
  }
1179
1246
  }
1247
+ if (revokedRefreshMarker && store) {
1248
+ if (store.clearRevokedRefreshMarker?.({ platform: "openclaw", accountId }) !== true) {
1249
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite revoked marker after activation`);
1250
+ }
1251
+ }
1180
1252
  // Paired: link the report row via the authenticated endpoint, reusing the
1181
1253
  // SAME frozen device_id so the backend upserts the existing unpaired row.
1182
1254
  void reportPluginVersionSafe({
@@ -1373,13 +1445,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1373
1445
  // blanks them (auto-logout); otherwise persists the rotated pair.
1374
1446
  const persistConfigTokens = async (
1375
1447
  tokens: { accessToken: string; refreshToken: string } | null,
1376
- ): Promise<void> => {
1448
+ ): Promise<boolean> => {
1377
1449
  const mutateConfigFile = resolveMutateConfigFile();
1378
1450
  if (!mutateConfigFile) {
1379
1451
  log?.error?.(
1380
1452
  `[${accountId}] clawchat-plugin-openclaw config persistence unavailable; cannot ${tokens ? "rotate" : "clear"} tokens in config`,
1381
1453
  );
1382
- return;
1454
+ return false;
1383
1455
  }
1384
1456
  await mutateConfigFile({
1385
1457
  afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
@@ -1393,6 +1465,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1393
1465
  );
1394
1466
  },
1395
1467
  });
1468
+ return true;
1396
1469
  };
1397
1470
 
1398
1471
  let lastHelloFailTraceId = "-";
@@ -1414,6 +1487,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1414
1487
  let reconnectWithRefreshedToken = false;
1415
1488
  // §C — set once auto-logout has fired so we don't double-emit or reconnect.
1416
1489
  let autoLoggedOut = false;
1490
+ let refreshClient: ClawlingChatClient | undefined;
1491
+ let autoLogoutCleanup: Promise<void> | undefined;
1417
1492
 
1418
1493
  // §E — connect-time device id for `X-Device-Id` on refresh. Prefer the value
1419
1494
  // recorded in SQLite at connect; backfill legacy rows (no column) to the
@@ -1426,37 +1501,62 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1426
1501
  // §C — auto-logout on permanent refresh failure. Blank creds in BOTH stores
1427
1502
  // (KEEP identity), flip not-configured via the auth-failure status path, and
1428
1503
  // emit the user-visible message. Idempotent.
1429
- const performAutoLogout = async (info: { code: number; message: string }): Promise<void> => {
1430
- if (autoLoggedOut) return;
1504
+ const performAutoLogout = (
1505
+ info: { code: number; message: string }, marker: RevokedRefreshMarker,
1506
+ ): Promise<void> => {
1507
+ if (autoLogoutCleanup) return autoLogoutCleanup;
1431
1508
  autoLoggedOut = true;
1432
1509
  log?.error?.(
1433
1510
  `[${accountId}] clawchat-plugin-openclaw auto-logout (token permanently expired) code=${info.code}: ${info.message}`,
1434
1511
  );
1435
- // SQLite: blank access/refresh, keep user/owner/device for re-pair.
1436
- if (store?.clearActivationCredentials) {
1437
- recordConnection("clear activation credentials", () =>
1438
- store.clearActivationCredentials?.({ platform: "openclaw", accountId }),
1439
- );
1440
- }
1441
- // Config: blank token/refreshToken keys.
1442
- try {
1443
- await persistConfigTokens(null);
1444
- } catch (err) {
1445
- log?.error?.(
1446
- `[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout: ${err instanceof Error ? err.message : String(err)}`,
1447
- );
1448
- }
1449
- // Flip not-configured (existing auth-failure status path) with the re-pair
1450
- // hint as `lastError`.
1451
- setStatus({
1452
- ...getStatus(),
1453
- connected: false,
1454
- configured: false,
1455
- running: false,
1456
- lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1457
- });
1458
- // User-visible notification (in addition to logs). Best-effort; never throws.
1459
- emitUserVisibleAuthLogout();
1512
+ refreshManager.stop();
1513
+ activeClients.delete(accountId);
1514
+ try { refreshClient?.close(); } catch { /* Best effort shutdown. */ }
1515
+ // Write the marker independently BEFORE clearing, so a failed clear cannot
1516
+ // resurrect this rejected credential at the next process start.
1517
+ autoLogoutCleanup = (async () => {
1518
+ const markerInput = { platform: "openclaw", accountId, ...marker };
1519
+ try {
1520
+ if (store?.setRevokedRefreshMarker?.(markerInput) !== true) {
1521
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1522
+ }
1523
+ } catch {
1524
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1525
+ }
1526
+ try {
1527
+ if (store?.clearActivationCredentials?.({ platform: "openclaw", accountId }) !== true) {
1528
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1529
+ }
1530
+ } catch {
1531
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1532
+ }
1533
+ try {
1534
+ if (!await persistConfigTokens(null)) throw new Error("config mutation unavailable");
1535
+ } catch {
1536
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout`);
1537
+ }
1538
+ // Flip not-configured (existing auth-failure status path) with the re-pair
1539
+ // hint as `lastError`.
1540
+ setStatus({
1541
+ ...getStatus(),
1542
+ connected: false,
1543
+ configured: false,
1544
+ running: false,
1545
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1546
+ });
1547
+ // User-visible notification (in addition to logs). Best-effort; never throws.
1548
+ emitUserVisibleAuthLogout();
1549
+ })();
1550
+ // Assign the lifetime before yielding, including when config clearing is
1551
+ // still pending and closing the old socket interrupts client.connect().
1552
+ lifecycle.rePairWait = autoLogoutCleanup.then(() => startOpenclawClawlingGateway({
1553
+ ...params, boundaryWarningIssued: true,
1554
+ account: { ...account, configured: false, token: "" },
1555
+ revokedRefreshMarker: marker, refreshTokenOverride: undefined,
1556
+ refreshManagerState: undefined, rejectedActivationToken: undefined,
1557
+ refreshReconnectDepth: undefined, refreshReconnectWindowStartedAt: undefined,
1558
+ }));
1559
+ return autoLogoutCleanup;
1460
1560
  };
1461
1561
 
1462
1562
  // §C.1.4 — surface the permanent-expiry message to the user/operator. The
@@ -1491,7 +1591,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1491
1591
  (activationAccount.source === "sqlite" && store?.getActivationCredentials
1492
1592
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
1493
1593
  : null) ??
1494
- readConfigRefreshToken(cfg, accountId);
1594
+ readConfigRefreshToken(cfg, accountId, revokedRefreshMarker);
1495
1595
 
1496
1596
  const refreshManager = new RefreshManager({
1497
1597
  baseUrl: account.baseUrl,
@@ -1551,7 +1651,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1551
1651
  // No SQLite store available — channel config is the only durable
1552
1652
  // store, so its write failure must reject and keep the refresh
1553
1653
  // transient (pre-existing behavior for store-less test transports).
1554
- await persistConfigTokens(tokens);
1654
+ if (!await persistConfigTokens(tokens)) {
1655
+ throw new Error("clawchat-plugin-openclaw config persistence unavailable");
1656
+ }
1555
1657
  },
1556
1658
  swapInMemoryTokens: (tokens) => {
1557
1659
  account = { ...account, token: tokens.accessToken, configured: true };
@@ -1830,7 +1932,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1830
1932
  const startupOutcome = await refreshManager.refresh("startup-near-expiry");
1831
1933
  if (abortSignal.aborted) return;
1832
1934
  if (startupOutcome.kind === "permanent") {
1833
- // Auto-logout already performed by the manager's onPermanentFailure.
1935
+ // Wait for re-pair or abort rather than settling the host channel task.
1936
+ await lifecycle.rePairWait;
1834
1937
  return;
1835
1938
  }
1836
1939
  // success swaps the in-memory token in place; transient/skipped just connect
@@ -1878,6 +1981,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1878
1981
  },
1879
1982
  },
1880
1983
  });
1984
+ refreshClient = client;
1881
1985
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);
1882
1986
 
1883
1987
  // §A.3/§A.4 — recompute the refresh-driven reconnect streak (depth + window).
@@ -2018,7 +2122,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2018
2122
  }
2019
2123
  const outcome = await refreshManager.refresh(reason);
2020
2124
  if (abortSignal.aborted) return "fallthrough";
2021
- if (autoLoggedOut) return "handled"; // permanent → manager auto-logged-out.
2125
+ if (autoLoggedOut) {
2126
+ await lifecycle.rePairWait;
2127
+ return "handled";
2128
+ }
2022
2129
  if (outcome.kind === "success") {
2023
2130
  await closeAndReconnectWithRefreshedToken(reason);
2024
2131
  return "handled";
@@ -3443,7 +3550,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3443
3550
  return "claimed";
3444
3551
  };
3445
3552
 
3446
- const dispatchTurnToAgent = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
3553
+ // The group's title from its metadata file, which the turn's profile sync
3554
+ // has just refreshed (so a group's first command already has it); the
3555
+ // group id when none is on record.
3556
+ const groupTitleForReceipt = async (groupId: string, memoryRoot: string): Promise<string> => {
3557
+ try {
3558
+ const file = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: groupId });
3559
+ const title = file.exists ? file.metadata.group_title : undefined;
3560
+ if (typeof title === "string" && title.trim()) return title.trim();
3561
+ } catch {
3562
+ // Fall back to the id below.
3563
+ }
3564
+ return groupId;
3565
+ };
3566
+
3567
+ const dispatchTurnToAgent = async (
3568
+ turn: IngestTurnParams,
3569
+ options: { ownerGroupCommand?: string } = {},
3570
+ ): Promise<IngestTurnResult> => {
3447
3571
  const rt = runtime.channel;
3448
3572
  const routeCfg = withClawChatSessionScope(cfg);
3449
3573
  const route = rt.routing.resolveAgentRoute({
@@ -3475,6 +3599,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3475
3599
  );
3476
3600
  }
3477
3601
  turn = await withCachedGroupSenderNames(turn, memoryRoot);
3602
+ const ownerCommandReceipt = options.ownerGroupCommand && turn.peer.kind === "group"
3603
+ ? {
3604
+ header: ownerCommandReceiptHeader(
3605
+ await groupTitleForReceipt(turn.peer.id, memoryRoot),
3606
+ options.ownerGroupCommand,
3607
+ ),
3608
+ commandMessageId: turn.messageId,
3609
+ }
3610
+ : undefined;
3478
3611
  const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
3479
3612
  const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
3480
3613
  // Owner turn <=> the owner's activation direct chat. Resolved per turn;
@@ -3669,6 +3802,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3669
3802
  // member-backend). Passed as a thunk so it is resolved at send time.
3670
3803
  resolveOwnerConversationId: resolveOwnerDirectConversationId,
3671
3804
  nonOwnerTurn: turnClass === "non-owner",
3805
+ ...(ownerCommandReceipt ? { ownerCommandReceipt } : {}),
3672
3806
  log,
3673
3807
  });
3674
3808
 
@@ -3758,8 +3892,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3758
3892
  // return on abort, but only AFTER it has already taken the idempotency claim
3759
3893
  // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
3760
3894
  // persisted before the interruptible wait — required by legacy advance-on-
3761
- // write delivery, where a frame written before a disconnect is not re-delivered
3762
- // on reconnect; see docs/client-integration.md §11.7). So even an ingest-time
3895
+ // write delivery, where a frame written before a disconnect is not reliably
3896
+ // re-delivered on reconnect (legacy may lose OR replay; a replay is deduped by
3897
+ // the claim); see docs/client-integration.md §11.7). So even an ingest-time
3763
3898
  // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
3764
3899
  // likewise already claimed at ingest AND `flush()` has already removed it from
3765
3900
  // the coalescer's pending set. An abort early-return would therefore silently
@@ -3883,7 +4018,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3883
4018
  const senderBatchIdentity = rawTurn.peer.kind === "group"
3884
4019
  ? resolveSenderBatchIdentityForTurn(rawTurn)
3885
4020
  : {};
3886
- const turn: IngestTurnParams = {
4021
+ let turn: IngestTurnParams = {
3887
4022
  ...rawTurn,
3888
4023
  senderNickName: resolveSenderNickNameForTurn(rawTurn),
3889
4024
  ...senderBatchIdentity,
@@ -3895,11 +4030,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3895
4030
  // can interrupt. This client does NOT advertise `reliable_delivery`
3896
4031
  // (src/ws-client.ts), so under legacy delivery the server's replay cursor
3897
4032
  // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
3898
- // already written before a disconnect is NOT re-delivered on reconnect.
4033
+ // already written before a disconnect is NOT reliably re-delivered on
4034
+ // reconnect. Legacy is not at-most-once either — a multi-replica server
4035
+ // (relayed frames do not advance the cursor) or a failed cursor write can
4036
+ // replay frames already received — and the claim below dedupes those.
3899
4037
  // `claimMessageOnce` is the ONLY durability an inbound has, and the store
3900
4038
  // never reprocesses undispatched inbounds. So if we claimed only AFTER the
3901
- // wait, a shutdown-mid-wait would leave the frame neither persisted NOR
3902
- // replayed — permanently lost. Claiming first makes it durable; the abort
4039
+ // wait, a shutdown-mid-wait would leave the frame unpersisted with no
4040
+ // guaranteed replay — lost. Claiming first makes it durable; the abort
3903
4041
  // check below then returns without dispatching, but the frame is kept.
3904
4042
  // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
3905
4043
  // message_id) is still deduped here exactly once. The claim fails CLOSED:
@@ -3934,7 +4072,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3934
4072
  );
3935
4073
  return "stored_muted";
3936
4074
  }
3937
- if (isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)) {
4075
+ // `@agent /cmd` counts as `/cmd` once the leading mentions are off.
4076
+ const commandText = isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)
4077
+ ? turn.rawBody
4078
+ : turn.addressedCommandBody && isKnownOpenClawGroupSlashCommand(turn.addressedCommandBody, cfg)
4079
+ ? turn.addressedCommandBody
4080
+ : null;
4081
+ if (commandText) {
4082
+ turn = { ...turn, rawBody: commandText };
3938
4083
  if (isOpenClawConfirmSlashCommand(turn.rawBody)) {
3939
4084
  return forwardGroupApprovalSlashToOwner(turn);
3940
4085
  }
@@ -3947,10 +4092,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3947
4092
  );
3948
4093
  return "skipped";
3949
4094
  }
4095
+ // The owner's command answers in the owner's direct chat; a command
4096
+ // that starts an agent turn, or anyone else's, answers in the group.
4097
+ const receiptToOwner = turn.senderIsOwner === true && !opensAgentTurn(commandText);
3950
4098
  log?.info?.(
3951
- `[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode}`,
4099
+ `[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} receipt=${receiptToOwner ? "owner" : "group"}`,
3952
4100
  );
3953
- return dispatchTurnToAgent(turn);
4101
+ return dispatchTurnToAgent(turn, receiptToOwner ? { ownerGroupCommand: commandText } : {});
3954
4102
  }
3955
4103
  if (eff.replyMode === "mention" && !turn.wasMentioned) {
3956
4104
  log?.info?.(
@@ -4226,6 +4374,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
4226
4374
  await client.connect();
4227
4375
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
4228
4376
  } catch (err) {
4377
+ if (autoLoggedOut) {
4378
+ await lifecycle.rePairWait;
4379
+ return;
4380
+ }
4229
4381
  const classified = classifyClawlingClientError(err);
4230
4382
  if (classified.kind === "auth") {
4231
4383
  // §A.2/§B — initial-connect hello-fail(auth). Do NOT pre-flip
@@ -4301,6 +4453,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
4301
4453
  );
4302
4454
  return;
4303
4455
  }
4456
+ if (autoLoggedOut) {
4457
+ await lifecycle.rePairWait;
4458
+ return;
4459
+ }
4304
4460
  activeClients.set(accountId, client);
4305
4461
  // Text "@name" autolinking on outbound group sends reads the same cached
4306
4462
  // roster (participant ids + nicknames) the group prompt shows the model.
package/src/storage.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RevokedRefreshMarker } from "./refresh-manager.ts";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { DatabaseSync } from "node:sqlite";
@@ -427,6 +428,19 @@ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
427
428
  sql: `
428
429
  CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
429
430
  ON clawchat_messages(chat_id COLLATE NOCASE);
431
+ `,
432
+ },
433
+ {
434
+ version: 13,
435
+ name: "revoked_refresh_tokens",
436
+ sql: `
437
+ CREATE TABLE IF NOT EXISTS revoked_refresh_tokens (
438
+ platform TEXT NOT NULL,
439
+ account_id TEXT NOT NULL,
440
+ fingerprint TEXT NOT NULL,
441
+ revoked_at INTEGER NOT NULL,
442
+ PRIMARY KEY (platform, account_id)
443
+ );
430
444
  `,
431
445
  },
432
446
  ];
@@ -600,6 +614,34 @@ export class ClawChatStore {
600
614
  });
601
615
  }
602
616
 
617
+ setRevokedRefreshMarker(input: ClearActivationCredentialsInput & RevokedRefreshMarker): boolean | null {
618
+ return this.write(() => {
619
+ this.requireDb().prepare(`INSERT INTO revoked_refresh_tokens
620
+ (platform, account_id, fingerprint, revoked_at) VALUES (?, ?, ?, ?)
621
+ ON CONFLICT(platform, account_id) DO UPDATE SET
622
+ fingerprint = excluded.fingerprint, revoked_at = excluded.revoked_at`)
623
+ .run(input.platform, input.accountId, input.fingerprint, input.revokedAt);
624
+ return true;
625
+ });
626
+ }
627
+
628
+ getRevokedRefreshMarker(input: ClearActivationCredentialsInput): RevokedRefreshMarker | null {
629
+ return this.read(() => {
630
+ const row = this.requireDb().prepare(`SELECT fingerprint, revoked_at AS revokedAt
631
+ FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
632
+ .get(input.platform, input.accountId) as RevokedRefreshMarker | undefined;
633
+ return row ?? null;
634
+ }) ?? null;
635
+ }
636
+
637
+ clearRevokedRefreshMarker(input: ClearActivationCredentialsInput): boolean | null {
638
+ return this.write(() => {
639
+ this.requireDb().prepare(`DELETE FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
640
+ .run(input.platform, input.accountId);
641
+ return true;
642
+ });
643
+ }
644
+
603
645
  getActivationCredentials(input: ConversationAccountInput): ActivationCredentials | null {
604
646
  return this.read(() => {
605
647
  const row = this.requireDb()