@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.17-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.
@@ -14,24 +14,16 @@ import {
14
14
  resolveOpenclawClawlingAccount,
15
15
  } from "./config.ts";
16
16
  import { getClawChatStore, type ClawChatStore } from "./storage.ts";
17
-
18
- /**
19
- * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
20
- * agent runtime — openclaw's bundled clawchat channel.
21
- */
22
- export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
23
- /**
24
- * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
25
- * always a bot; humans don't log in through this flow.
26
- */
27
- export const AGENTS_CONNECT_TYPE = "clawbot" as const;
28
- /**
29
- * `/v1/agents/connect` envelope code for "the supplied user_id matches no
30
- * agent". Servers that predate the server-side stale-user_id fallback return
31
- * this instead of degrading to a fresh pairing, so the client sheds the id and
32
- * retries once.
33
- */
34
- export const AGENT_NOT_FOUND_CODE = 16001 as const;
17
+ import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
18
+ import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.ts";
19
+ // Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
20
+ // from here, so the historical import path keeps working after the move.
21
+ import {
22
+ AGENTS_CONNECT_PLATFORM,
23
+ AGENTS_CONNECT_TYPE,
24
+ AGENT_NOT_FOUND_CODE,
25
+ } from "./onboarding-context.ts";
26
+ export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
35
27
 
36
28
  export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
37
29
  afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
@@ -103,9 +95,11 @@ export class ExistingActivationError extends Error {
103
95
  "spent. Re-run stating the intent:\n" +
104
96
  " - Pair as a BRAND-NEW agent (this instance stops using the identity " +
105
97
  "above): /clawchat-activate <CODE> --new-account\n" +
106
- " - RESTORE the identity above (re-pairs that same agent; if it was " +
107
- "deleted this brings it back with its history): " +
108
- "/clawchat-activate <CODE> --repair\n" +
98
+ " - RESTORE the identity above: do not spend a fresh code on it. Ask your " +
99
+ "owner to send you the reconnect prompt from the ClawChat app — its code is " +
100
+ "bound to this identity and usually restores it on its own; if activation " +
101
+ "still reports it as already paired, run it again with --repair " +
102
+ `(/clawchat-activate <CODE> --repair) — and follow ${RECONNECT_GUIDE_URL}\n` +
109
103
  " - To run a SECOND agent alongside this one instead, activate it as a " +
110
104
  "named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
111
105
  "channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)",
@@ -289,7 +283,63 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
289
283
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
290
284
  const account = resolveOpenclawClawlingAccount(cfg, accountId);
291
285
 
292
- // Fork before the invite code is even read, so neither branch spends the code
286
+ const inviteCode = (
287
+ await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
288
+ ).trim();
289
+ if (!inviteCode) {
290
+ throw new Error("Login aborted: invite code is required.");
291
+ }
292
+
293
+ const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
294
+ baseUrl: account.baseUrl,
295
+ mediaBaseUrl: account.mediaBaseUrl,
296
+ // Pre-login we may not have a token yet. Send the current one (or empty)
297
+ // — the server should accept an unauthenticated invite-code exchange.
298
+ token: account.token || "",
299
+ pluginVersion: resolvePluginVersion(),
300
+ });
301
+
302
+ // Telemetry the backend joins with wiki field reports; ignored by older servers.
303
+ const onboarding = buildOnboardingContext();
304
+
305
+ // Non-consuming pre-check. `null` = endpoint unavailable, carry on. A bound
306
+ // code (the owner's reconnect prompt) is the owner's own proof of intent, so
307
+ // it settles the "new agent or restore?" question below without a flag.
308
+ //
309
+ // The pre-check only carries the `user_id` that `/connect` would replay. An
310
+ // explicit `newAccount` never replays it, so it must not be judged against
311
+ // it either: the server marks `pairable:false` for an `owner_mismatch` /
312
+ // `invalid` id, and that would block the very escape the refusal recommends.
313
+ const storedUserId = account.userId.trim();
314
+ const precheckUserId = params.newAccount ? "" : storedUserId;
315
+ runtime.log("Checking the invite code …");
316
+ let precheck = await preCheckConnectCode(
317
+ apiClient,
318
+ { code: inviteCode, userId: precheckUserId || undefined, context: onboarding },
319
+ runtime.log,
320
+ );
321
+ // Whether the operator will be asked "new agent or restore?" below. When
322
+ // they will, a refusal caused only by the stored identity (not by the code)
323
+ // is deferred: choosing a brand-new identity drops that id, so the verdict
324
+ // on it no longer applies.
325
+ const identityUnsettled =
326
+ storedUserId !== "" && account.token.trim() !== "" && !params.newAccount && !params.repair;
327
+ const refusedForIdentity =
328
+ precheck !== null &&
329
+ !precheck.pairable &&
330
+ precheckUserId !== "" &&
331
+ // Only a live code: a dead one (expired / paired / invalid) refuses on its
332
+ // own account whatever identity is chosen, so there is nothing to defer.
333
+ precheck.status === "pending" &&
334
+ (precheck.userIdStatus === "owner_mismatch" || precheck.userIdStatus === "invalid");
335
+ const deferredRefusal = identityUnsettled && refusedForIdentity ? precheck : null;
336
+ if (precheck && !precheck.pairable && !deferredRefusal) {
337
+ throw new Error(unpairableMessage(precheck));
338
+ }
339
+ const boundToIncumbent =
340
+ precheck?.pairable === true && precheck.boundAgent === true && precheckUserId !== "";
341
+
342
+ // Fork before the invite code is spent, so neither branch spends the code
293
343
  // before the outcome is settled. "Live" is decided by whether a usable token
294
344
  // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
295
345
  // logged-out instance still re-pairs with no flag at all, which is the flow
@@ -299,50 +349,60 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
299
349
  // ambiguous, because redeeming a code here can either mint a new agent or
300
350
  // re-pair the incumbent one. Ask which, and only refuse when nobody answers.
301
351
  // An explicit `newAccount` / `repair` from the caller has already settled it,
302
- // so no prompt.
352
+ // so no prompt. Nor is one needed when the pre-check proved the code is the
353
+ // owner's own reconnect prompt for this very agent (`boundToIncumbent`) — a
354
+ // bound code can only ever restore the incumbent identity.
303
355
  let newAccount = params.newAccount;
304
- if (account.userId.trim() && account.token.trim() && !newAccount && !params.repair) {
356
+ if (identityUnsettled && !boundToIncumbent) {
305
357
  const identity = account.agentId.trim()
306
- ? `agent ${account.agentId.trim()} (shadow user ${account.userId.trim()})`
307
- : `agent ${account.userId.trim()}`;
358
+ ? `agent ${account.agentId.trim()} (shadow user ${storedUserId})`
359
+ : `agent ${storedUserId}`;
308
360
  const intent = await (params.readActivationIntent ??
309
361
  (() => promptActivationIntentFromStdin(runtime, identity)))();
310
362
  // Only "new-account" changes what gets sent. Restoring needs no flag:
311
363
  // replaying the stored `user_id` IS the re-pair, and that is already the
312
364
  // default below — so choosing it simply means "don't refuse".
313
- if (intent === "new-account") newAccount = true;
314
- else if (intent === null) {
315
- throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
365
+ if (intent === "new-account") {
366
+ newAccount = true;
367
+ if (deferredRefusal) {
368
+ // The first verdict judged the id we are now dropping; re-check the
369
+ // code on its own before spending it.
370
+ precheck = await preCheckConnectCode(
371
+ apiClient,
372
+ { code: inviteCode, context: onboarding },
373
+ runtime.log,
374
+ );
375
+ if (precheck && !precheck.pairable) {
376
+ throw new Error(unpairableMessage(precheck));
377
+ }
378
+ }
379
+ } else if (deferredRefusal) {
380
+ // Restoring (or nobody to ask) replays the id the server already refused.
381
+ throw new Error(unpairableMessage(deferredRefusal));
382
+ } else if (intent === null) {
383
+ throw new ExistingActivationError(storedUserId, account.agentId.trim());
316
384
  }
317
385
  }
318
386
 
319
- const inviteCode = (
320
- await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
321
- ).trim();
322
- if (!inviteCode) {
323
- throw new Error("Login aborted: invite code is required.");
387
+ // A new identity (flag or interactive choice) sends no user_id, and /connect
388
+ // without one on a bound code silently RESTORES the bound agent and spends
389
+ // the code. A reconnect code can never mint an agent, so refuse first.
390
+ if (newAccount && precheck?.boundAgent === true) {
391
+ throw new Error(boundCodeNewIdentityMessage());
324
392
  }
325
393
 
326
- const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
327
- baseUrl: account.baseUrl,
328
- mediaBaseUrl: account.mediaBaseUrl,
329
- // Pre-login we may not have a token yet. Send the current one (or empty)
330
- // — the server should accept an unauthenticated invite-code exchange.
331
- token: account.token || "",
332
- pluginVersion: resolvePluginVersion(),
333
- });
334
-
335
394
  runtime.log("Verifying invite code …");
336
395
  let result;
337
396
  // A brand-new identity is precisely "do not replay" — the replay is the only
338
397
  // thing that would bind this code to the incumbent agent.
339
- const existingUserId = newAccount ? "" : account.userId.trim();
398
+ const existingUserId = newAccount ? "" : storedUserId;
340
399
  try {
341
400
  result = await apiClient.agentsConnect({
342
401
  code: inviteCode,
343
402
  platform: AGENTS_CONNECT_PLATFORM,
344
403
  type: AGENTS_CONNECT_TYPE,
345
404
  ...(existingUserId ? { user_id: existingUserId } : {}),
405
+ context: onboarding,
346
406
  });
347
407
  } catch (err) {
348
408
  if (err instanceof ClawlingApiError) {
@@ -363,6 +423,7 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
363
423
  code: inviteCode,
364
424
  platform: AGENTS_CONNECT_PLATFORM,
365
425
  type: AGENTS_CONNECT_TYPE,
426
+ context: onboarding,
366
427
  });
367
428
  } catch (retryErr) {
368
429
  if (retryErr instanceof ClawlingApiError) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Onboarding telemetry the plugin attaches to `/v1/agents/connect` and
3
+ * `/v1/agents/connect/check` (all optional, all ignored by older backends).
4
+ *
5
+ * Values mirror the connection wiki's manifest vocabulary so the backend can
6
+ * join plugin activations with wiki field reports without an alias table:
7
+ * - `agent_kind` = the wiki `match.agent` name for this host
8
+ * - `lane` = "self": this plugin holds its own WebSocket + token
9
+ * - `matched_install` = the wiki page slug that routes to this plugin
10
+ * Nothing here is a credential and nothing here gates activation.
11
+ */
12
+
13
+ export const AGENT_KIND = "openclaw" as const;
14
+ export const LANE = "self" as const;
15
+ export const MATCHED_INSTALL = "install/official-openclaw" as const;
16
+
17
+ /** Public connection wiki. `/start.md` is the entry page; `/reconnect.md` the re-pair page. */
18
+ export const START_GUIDE_URL = "https://agent-connection.clawling.com/start.md" as const;
19
+ export const RECONNECT_GUIDE_URL = "https://agent-connection.clawling.com/reconnect.md" as const;
20
+
21
+ const WIKI_VERSION_MAX = 40;
22
+
23
+ /** Wiki `os` enum (windows | macos | linux); "" for anything else so callers omit it. */
24
+ export function hostOs(plat: string = process.platform): "windows" | "macos" | "linux" | "" {
25
+ switch (plat) {
26
+ case "win32":
27
+ return "windows";
28
+ case "darwin":
29
+ return "macos";
30
+ case "linux":
31
+ return "linux";
32
+ default:
33
+ return "";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The wiki build stamp the agent saw on the install page. The official install
39
+ * page tells the agent to export `CLAWCHAT_WIKI_VERSION` before activating; a
40
+ * hand-run `openclaw channels add` simply has none.
41
+ */
42
+ export function wikiVersion(env: NodeJS.ProcessEnv = process.env): string {
43
+ return (env.CLAWCHAT_WIKI_VERSION ?? "").trim().slice(0, WIKI_VERSION_MAX);
44
+ }
45
+
46
+ export function buildOnboardingContext(
47
+ opts: { platform?: string; env?: NodeJS.ProcessEnv } = {},
48
+ ): Record<string, string> {
49
+ const out: Record<string, string> = {
50
+ agent_kind: AGENT_KIND,
51
+ lane: LANE,
52
+ matched_install: MATCHED_INSTALL,
53
+ };
54
+ const os = hostOs(opts.platform);
55
+ if (os) out.os = os;
56
+ const wiki = wikiVersion(opts.env);
57
+ if (wiki) out.wiki_version = wiki;
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
63
+ * agent runtime — openclaw's bundled clawchat channel.
64
+ */
65
+ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
66
+ /**
67
+ * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
68
+ * always a bot; humans don't log in through this flow.
69
+ */
70
+ export const AGENTS_CONNECT_TYPE = "clawbot" as const;
71
+ /**
72
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
73
+ * agent". Servers that predate the server-side stale-user_id fallback return
74
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
75
+ * retries once.
76
+ */
77
+ export const AGENT_NOT_FOUND_CODE = 16001 as const;
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Optional onboarding facts the AGENT writes after following the connection
7
+ * wiki: the id of the field report it filed there, and its self-assessed
8
+ * capability tier. The plugin only relays them on the plugin-report call; it
9
+ * never files a wiki report itself. Same directory as the greeting override
10
+ * (`~/clawchat/`), so the agent already knows the place.
11
+ */
12
+ export interface OnboardingReportFields {
13
+ wiki_report_id?: string;
14
+ capability_tier?: number;
15
+ capability_ceiling?: number;
16
+ capabilities?: { headless?: boolean; mcp?: boolean; permission_hook?: boolean; session_line?: boolean };
17
+ }
18
+
19
+ const REPORT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
20
+ const CAP_KEYS = ["headless", "mcp", "permission_hook", "session_line"] as const;
21
+
22
+ function tier(v: unknown): number | undefined {
23
+ return Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 4 ? (v as number) : undefined;
24
+ }
25
+
26
+ /**
27
+ * Read + validate `~/clawchat/onboarding.json`. Never throws: an unresolvable
28
+ * home directory, an absent/unreadable file, or malformed JSON all fall
29
+ * through to `null`, same as a file with no valid fields — this must be safe
30
+ * to call unconditionally from a best-effort report path.
31
+ */
32
+ export function readOnboardingReport(homeDir?: string): OnboardingReportFields | null {
33
+ try {
34
+ const base = homeDir ?? os.homedir();
35
+ const file = path.join(base, "clawchat", "onboarding.json");
36
+ const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
37
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
38
+ const o = raw as Record<string, unknown>;
39
+ const out: OnboardingReportFields = {};
40
+ if (typeof o.wiki_report_id === "string" && REPORT_ID_RE.test(o.wiki_report_id)) out.wiki_report_id = o.wiki_report_id;
41
+ const t = tier(o.capability_tier);
42
+ if (t !== undefined) out.capability_tier = t;
43
+ const c = tier(o.capability_ceiling);
44
+ // The backend rejects the WHOLE report (22004) when ceiling < tier, which
45
+ // would silently stop the version row updating. Keep the tier, drop the
46
+ // inconsistent ceiling.
47
+ if (c !== undefined && (t === undefined || c >= t)) out.capability_ceiling = c;
48
+ if (o.capabilities && typeof o.capabilities === "object") {
49
+ const caps: NonNullable<OnboardingReportFields["capabilities"]> = {};
50
+ for (const k of CAP_KEYS) {
51
+ const v = (o.capabilities as Record<string, unknown>)[k];
52
+ if (typeof v === "boolean") caps[k] = v;
53
+ }
54
+ if (Object.keys(caps).length > 0) out.capabilities = caps;
55
+ }
56
+ return Object.keys(out).length > 0 ? out : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
5
+ import type { OnboardingReportFields } from "./onboarding-report.ts";
5
6
 
6
7
  const PACKAGE_NAME = "@clawling/clawchat-plugin-openclaw";
7
8
 
@@ -48,6 +49,8 @@ export interface ReportParams {
48
49
  pluginVersion: string;
49
50
  agentVersion: string;
50
51
  authenticated: boolean;
52
+ /** Agent-written onboarding facts (`~/clawchat/onboarding.json`); optional. */
53
+ onboarding?: OnboardingReportFields | null;
51
54
  log?: { debug?: (msg: string) => void };
52
55
  }
53
56
 
@@ -70,6 +73,7 @@ export async function reportPluginVersionSafe(p: ReportParams): Promise<void> {
70
73
  agentVersion: p.agentVersion,
71
74
  runtimeName: "node",
72
75
  runtimeVersion: process.version,
76
+ onboarding: p.onboarding ?? null,
73
77
  },
74
78
  { authenticated: p.authenticated },
75
79
  );
@@ -17,6 +17,7 @@ export const EVENT = {
17
17
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
18
18
  NOTIFY_SIGNAL: "notify.signal",
19
19
  REPLAY_DONE: "replay.done",
20
+ HISTORY_TRUNCATED: "history.truncated",
20
21
  OFFLINE_BATCH: "offline.batch",
21
22
  OFFLINE_ACK: "offline.ack",
22
23
  OFFLINE_DONE: "offline.done",
@@ -30,6 +30,15 @@ const HOUR_MS = 60 * MINUTE_MS;
30
30
  export const MIN_REFRESH_INTERVAL_MS = 30_000;
31
31
  /** §A.1 — proactive jitter (±5min). */
32
32
  export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
33
+ /**
34
+ * §B retry within the grace window — jitter bounds for the one-shot retry after
35
+ * a transient proactive refresh. The retry is due `MIN_REFRESH_INTERVAL_MS` +
36
+ * [1s, 5s] after the failed attempt BEGAN (31–35s): far above the backend's
37
+ * minimum replay age, inside its 90s replay window, and never faster than the
38
+ * min-interval floor (which would skip it).
39
+ */
40
+ export const PROACTIVE_RETRY_JITTER_MIN_MS = 1_000;
41
+ export const PROACTIVE_RETRY_JITTER_MAX_MS = 5_000;
33
42
  /** §A.0 — fallback access-token TTL when `exp` is unparseable. */
34
43
  export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
35
44
  /**
@@ -86,6 +95,8 @@ export interface RefreshManagerPorts {
86
95
  clearTimer?: (handle: TimerHandle) => void;
87
96
  /** Test override for jitter in [-PROACTIVE_JITTER_MS, +PROACTIVE_JITTER_MS]. */
88
97
  jitter?: () => number;
98
+ /** Test override for the proactive-retry jitter (clamped to [1s, 5s]). */
99
+ proactiveRetryJitter?: () => number;
89
100
  log?: { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
90
101
  }
91
102
 
@@ -136,6 +147,8 @@ export class RefreshManager {
136
147
  /** §A.3 — epoch-ms of the last refresh attempt (any token). */
137
148
  private lastAttemptAt = 0;
138
149
  private proactiveTimer: TimerHandle | null = null;
150
+ /** §B — the pending one-shot retry after a transient proactive refresh. */
151
+ private proactiveRetryTimer: TimerHandle | null = null;
139
152
  private stopped = false;
140
153
 
141
154
  constructor(private readonly ports: RefreshManagerPorts) {}
@@ -214,9 +227,10 @@ export class RefreshManager {
214
227
  // sqlite-sourced agent must not keep a now-dead refresh token in its row
215
228
  // while running on the rotated token. Treat as transient so the WS stays
216
229
  // in backoff with the CURRENT tokens and the next attempt retries. The
217
- // server already rotated, so the next attempt may return `code:10003`
218
- // (which escalates to permanent per §B) — that is the accepted hazard, not
219
- // a silent brick.
230
+ // server already rotated: inside the backend grace window the retry
231
+ // redeems the old token again; after it the retry returns `code:10003`
232
+ // (which escalates to permanent per §B) — the accepted hazard, not a
233
+ // silent brick.
220
234
  try {
221
235
  await this.ports.persistRotatedTokens({
222
236
  accessToken: result.accessToken,
@@ -243,8 +257,9 @@ export class RefreshManager {
243
257
  }
244
258
 
245
259
  if (result.kind === "permanent") {
246
- // §B race — a `code:10003` is also returned for a single-use refresh token
247
- // already CONSUMED by a prior successful rotation. Before auto-logging-out
260
+ // §B race — a `code:10003` is also returned for a refresh token already
261
+ // CONSUMED by a prior successful rotation (once the backend grace window
262
+ // no longer covers it). Before auto-logging-out
248
263
  // (which wipes credentials and bricks the agent), distinguish that race
249
264
  // from a genuine revocation. It is a race when EITHER the submitted token
250
265
  // is one we already rotated away from, OR the live store refresh token has
@@ -331,12 +346,24 @@ export class RefreshManager {
331
346
  * success, hands the rotated token to the runtime's `onProactiveRefreshed` port
332
347
  * so the live WS is closed and reconnected with the new token (the in-memory
333
348
  * swap alone does NOT reach the running socket, which captured the old token at
334
- * `connect` time). Transient/skipped outcomes leave the WS untouched — the next
335
- * proactive arm (or a reactive hello-fail) handles it.
349
+ * `connect` time). Transient/skipped outcomes leave the WS untouched.
350
+ *
351
+ * §B retry within the grace window — a TRANSIENT outcome (e.g. the attempt
352
+ * deadline hit after the server may already have rotated) arms ONE retry, due
353
+ * 31–35s after that attempt began, so a replay of the same refresh token lands
354
+ * inside the backend grace window instead of waiting for the next arm /
355
+ * hello-fail / 401, possibly hours later. The retry itself never arms another.
356
+ * Success, permanent and skipped outcomes arm nothing (skipped means another
357
+ * in-flight attempt or a latch already owns the token).
336
358
  */
337
- private async runProactiveRefresh(): Promise<void> {
338
- const outcome = await this.refresh("proactive-timer");
359
+ private async runProactiveRefresh(isRetry = false): Promise<void> {
360
+ const accessTokenAtAttempt = this.ports.getAccessToken();
361
+ const outcome = await this.refresh(isRetry ? "proactive-retry" : "proactive-timer");
339
362
  if (this.stopped) return;
363
+ if (outcome.kind === "transient") {
364
+ if (!isRetry) this.armProactiveRetry(accessTokenAtAttempt);
365
+ return;
366
+ }
340
367
  if (outcome.kind !== "success") return;
341
368
  if (this.ports.onProactiveRefreshed) {
342
369
  try {
@@ -352,6 +379,50 @@ export class RefreshManager {
352
379
  }
353
380
  }
354
381
 
382
+ /**
383
+ * §B — arm the one-shot retry, measured from the failed attempt's start
384
+ * (`lastAttemptAt`). It runs through `refresh()`, so single-flight dedupe, the
385
+ * rejected-token latch and the min-interval floor all still apply. It is
386
+ * skipped when the access token changed meanwhile (someone else rotated).
387
+ */
388
+ private armProactiveRetry(accessTokenAtAttempt: string): void {
389
+ this.clearProactiveRetry();
390
+ const rawJitter = (this.ports.proactiveRetryJitter ?? defaultProactiveRetryJitter)();
391
+ const jitterMs = Math.min(
392
+ PROACTIVE_RETRY_JITTER_MAX_MS,
393
+ Math.max(PROACTIVE_RETRY_JITTER_MIN_MS, rawJitter),
394
+ );
395
+ const dueAtMs = this.lastAttemptAt + MIN_REFRESH_INTERVAL_MS + jitterMs;
396
+ const delayMs = Math.max(0, dueAtMs - this.now());
397
+ this.ports.log?.info?.(
398
+ `clawchat-plugin-openclaw proactive refresh transient; one-shot retry in ${delayMs}ms`,
399
+ );
400
+ this.proactiveRetryTimer = this.setTimer(() => {
401
+ this.proactiveRetryTimer = null;
402
+ if (this.stopped) return;
403
+ if (this.ports.getAccessToken() !== accessTokenAtAttempt) {
404
+ this.ports.log?.debug?.(
405
+ "clawchat-plugin-openclaw proactive retry skipped (access token already changed)",
406
+ );
407
+ return;
408
+ }
409
+ void this.runProactiveRefresh(true);
410
+ }, delayMs);
411
+ }
412
+
413
+ private clearProactiveRetry(): void {
414
+ if (this.proactiveRetryTimer != null) {
415
+ this.clearTimer(this.proactiveRetryTimer);
416
+ this.proactiveRetryTimer = null;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Clear the proactive `refresh_at` timer (called on WS disconnect). The §B
422
+ * one-shot retry is deliberately NOT cleared here: a refresh timeout usually
423
+ * coincides with the network drop that also closes the socket, and refresh
424
+ * does not need the socket. `stop()` clears both.
425
+ */
355
426
  disarmProactiveTimer(): void {
356
427
  if (this.proactiveTimer != null) {
357
428
  this.clearTimer(this.proactiveTimer);
@@ -377,10 +448,11 @@ export class RefreshManager {
377
448
  return this.now() >= refreshAtMs;
378
449
  }
379
450
 
380
- /** Stop the manager — clears the proactive timer; no further refreshes arm. */
451
+ /** Stop the manager — clears the proactive + retry timers; nothing further arms. */
381
452
  stop(): void {
382
453
  this.stopped = true;
383
454
  this.disarmProactiveTimer();
455
+ this.clearProactiveRetry();
384
456
  }
385
457
 
386
458
  /** Test/inspection seam — the latched (rejected) access token, if any. */
@@ -435,3 +507,10 @@ function decodeJwtIat(token: string): number | null {
435
507
  function defaultJitter(): number {
436
508
  return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
437
509
  }
510
+
511
+ function defaultProactiveRetryJitter(): number {
512
+ return (
513
+ PROACTIVE_RETRY_JITTER_MIN_MS +
514
+ Math.random() * (PROACTIVE_RETRY_JITTER_MAX_MS - PROACTIVE_RETRY_JITTER_MIN_MS)
515
+ );
516
+ }