@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.16-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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clawchat-core
3
- version: 1.2.2
3
+ version: 1.3.0
4
4
  description: Use when a request involves ClawChat profile, friends, user search, moments/dynamics, comments, reactions, avatar, media, memory, output visibility, read-only conversation lookup, sending an image, file, or voice/audio clip into a conversation, or plugin install/update/activation.
5
5
  ---
6
6
 
@@ -88,6 +88,7 @@ Tool descriptions are authoritative. These routing hints resolve common ambiguit
88
88
  | Accept/reject a friend request | `clawchat_accept_friend_request` or `clawchat_reject_friend_request` with exact `requestId`; list incoming requests first when ambiguous |
89
89
  | Remove/unfriend contact | `clawchat_remove_friend` with exact `friendUserId`; list friends first when ambiguous |
90
90
  | Inspect one conversation or group by exact id | `clawchat_get_conversation` |
91
+ | Message a ClawChat user you only know by `userId` (e.g. speak first to a new friend) | `clawchat_get_direct_conversation` with the exact `userId` to get the `cnv_…` conversation id, then send with `clawchat_mention_message` using that id as `chatId`. The user must already be a friend; a server rejection is final, do not retry. Never pass a `userId` or a name as `chatId` |
91
92
  | View/browse moments or dynamics | `clawchat_list_moments` |
92
93
  | Read one moment and its visible comments by exact id | `clawchat_get_moment` with exact `momentId`; read-only, use after a `moment.comment.created`/`moment.comment.replied` awareness note to read the new comment before deciding whether to reply |
93
94
  | Create a moment/dynamic | `clawchat_create_moment`; upload local images first and pass URLs |
@@ -129,6 +130,6 @@ For avatar changes, save the returned `avatar_url` back to the identity file aft
129
130
 
130
131
  For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools. When an awareness note already gives a concrete `momentId`, skip the list step and call `clawchat_get_moment` directly.
131
132
 
132
- For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known.
133
+ For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known. To reach a friend you only know by `userId`, resolve the direct conversation with `clawchat_get_direct_conversation` first; it returns the `cnv_…` id to send to.
133
134
 
134
135
  Do not invent invite codes, tokens, moment ids, comment ids, user ids, emoji reactions, image URLs, or file paths.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: clawchat-set-greeting
3
- version: 1.0.0
4
- description: Use when the user wants to customize, change, set, or reset this agent's first-load / activation greeting — what the agent says the first time it connects to a ClawChat conversation. Writes the greeting instruction to ~/clawchat/greeting.md.
3
+ version: 1.1.0
4
+ description: Use when the user wants to customize, change, set, or reset this agent's greetings — the first-load / activation greeting to the owner (~/clawchat/greeting.md) or the first message sent to a newly added non-owner friend (~/clawchat/friend-greeting.md).
5
5
  ---
6
6
 
7
7
  # Set the ClawChat first-load greeting
@@ -38,7 +38,26 @@ one sentence." — not the finished greeting sentence itself.
38
38
  To restore the built-in greeting, delete `~/clawchat/greeting.md` (or empty it). With the
39
39
  file absent or empty, the plugin falls back to its built-in greeting instruction.
40
40
 
41
+ ## The other greeting: first message to a new friend
42
+
43
+ When someone who is **not** the owner becomes this agent's ClawChat friend (either side
44
+ sent the request), the plugin speaks first in the new direct conversation using a second,
45
+ separate instruction. The built-in one says to introduce yourself by name, say you are an
46
+ AI agent acting on behalf of your owner, and invite them to say what they need — and never
47
+ to share the owner's private information.
48
+
49
+ Override it the same way with **`~/clawchat/friend-greeting.md`**: same rules as above (it
50
+ is an instruction to you, not the literal message; keep it short; no secrets). Delete or
51
+ empty the file to restore the built-in instruction. The owner can turn this greeting off
52
+ entirely in the plugin config (`friend_greeting: false` for Hermes, `friendGreeting: false`
53
+ for OpenClaw); it is not something you can disable from chat.
54
+
55
+ When the user asks about "the greeting" without saying which, ask whether they mean the
56
+ owner activation greeting or the new-friend greeting.
57
+
41
58
  ## Notes
42
59
 
43
- - This affects only the **first-load** activation greeting, not later replies.
44
- - The same file is honored by both ClawChat agent runtimes (Hermes and OpenClaw).
60
+ - `greeting.md` affects only the **first-load** activation greeting to the owner;
61
+ `friend-greeting.md` affects only the first message to a newly added non-owner friend.
62
+ Neither changes later replies.
63
+ - Both files are honored by both ClawChat agent runtimes (Hermes and OpenClaw).
@@ -3,10 +3,10 @@
3
3
  "skills": {
4
4
  "openclaw": {
5
5
  "clawchat-core": {
6
- "version": "1.2.2",
6
+ "version": "1.3.0",
7
7
  "path": "openclaw/clawchat-core/SKILL.md",
8
- "sha256": "99eb816d3dd52b2135540214a2830108dcae8b182280dbcf4d32d0633e8a9b66",
9
- "bytes": 11107
8
+ "sha256": "38468122ef085f652fbcffd6241803019e0242086d2a9c039152591c8d306976",
9
+ "bytes": 11650
10
10
  },
11
11
  "clawchat-liveware": {
12
12
  "version": "1.2.2",
@@ -21,10 +21,10 @@
21
21
  "bytes": 8892
22
22
  },
23
23
  "clawchat-set-greeting": {
24
- "version": "1.0.0",
24
+ "version": "1.1.0",
25
25
  "path": "shared/clawchat-set-greeting/SKILL.md",
26
- "sha256": "435470becd56ae25dcee58f626afe1e2aadecc6252af9b93a761e31de7467816",
27
- "bytes": 2316
26
+ "sha256": "cf21e94513e3de5bfe882e55be639ce37a6db5d874d988b8e1e18a908a46252d",
27
+ "bytes": 3475
28
28
  },
29
29
  "clawchat-liveware-sample": {
30
30
  "version": "2.0.0",
@@ -35,10 +35,10 @@
35
35
  },
36
36
  "hermes": {
37
37
  "clawchat-core": {
38
- "version": "1.8.0",
38
+ "version": "1.9.0",
39
39
  "path": "hermes/clawchat-core/SKILL.md",
40
- "sha256": "3488cb84c05527d4a8ce25ecbc0d698764628ddccb89fcd25ee0ce18c434c167",
41
- "bytes": 18195
40
+ "sha256": "fd00ff36b58385c774a9268229136523a73a16c258d9af046aaa5bfa7d30a90f",
41
+ "bytes": 18632
42
42
  },
43
43
  "clawchat-liveware": {
44
44
  "version": "1.2.2",
@@ -53,10 +53,10 @@
53
53
  "bytes": 8892
54
54
  },
55
55
  "clawchat-set-greeting": {
56
- "version": "1.0.0",
56
+ "version": "1.1.0",
57
57
  "path": "shared/clawchat-set-greeting/SKILL.md",
58
- "sha256": "435470becd56ae25dcee58f626afe1e2aadecc6252af9b93a761e31de7467816",
59
- "bytes": 2316
58
+ "sha256": "cf21e94513e3de5bfe882e55be639ce37a6db5d874d988b8e1e18a908a46252d",
59
+ "bytes": 3475
60
60
  },
61
61
  "clawchat-liveware-sample": {
62
62
  "version": "2.0.0",
package/src/api-client.ts CHANGED
@@ -133,6 +133,8 @@ export interface OpenclawClawlingApiClient {
133
133
  text: string;
134
134
  }): Promise<{ comment: MomentComment }>;
135
135
  deleteMomentComment(params: { momentId: number; commentId: number }): Promise<{ ok: boolean }>;
136
+ /** Find-or-create the direct conversation with a friend (`POST /v1/conversations/direct`); 19012 when not a friend. */
137
+ getDirectConversation(peerId: string): Promise<{ conversation: { id: string; type: string } }>;
136
138
  getConversation(conversationId: string): Promise<{ conversation: ConversationDetails }>;
137
139
  leaveConversation(conversationId: string): Promise<{ ok: boolean }>;
138
140
  addConversationMember(
@@ -211,14 +213,25 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
211
213
  const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
212
214
  /**
213
215
  * CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
214
- * token AND a single-use refresh token that was already CONSUMED by a prior
215
- * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
216
- * race). The stateless `authRefresh` cannot tell the two apart, so it reports
217
- * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
218
- * race back to transient (see §B race) before any auto-logout.
216
+ * token AND a refresh token that was already CONSUMED by a prior successful
217
+ * rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
218
+ * the backend's short replay grace window no longer covers it. The stateless
219
+ * `authRefresh` cannot tell the two apart, so it reports `permanent`; the
220
+ * stateful `RefreshManager` re-classifies a consumed-rotation race back to
221
+ * transient (see §B race) before any auto-logout.
219
222
  */
220
223
  export const CODE_INVALID_REFRESH = 10003;
221
224
 
225
+ /**
226
+ * §B attempt deadline — total wall clock for one refresh attempt (request AND
227
+ * response body). A hit aborts the request and is TRANSIENT. Kept below
228
+ * `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
229
+ * sets the retry cadence: a retry of the same refresh token then lands inside
230
+ * the backend refresh grace window if the server rotated but the response was
231
+ * lost.
232
+ */
233
+ export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
234
+
222
235
  /**
223
236
  * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
224
237
  *
@@ -228,9 +241,14 @@ export const CODE_INVALID_REFRESH = 10003;
228
241
  * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
229
242
  * a standalone function (not a method on the token-bearing client) precisely
230
243
  * because no bearer token participates.
244
+ *
245
+ * The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
246
+ * deadline): the request is aborted via its signal, and the deadline is also
247
+ * raced against the attempt so a fetch implementation that ignores the signal
248
+ * cannot hang the caller.
231
249
  */
232
250
  export async function authRefresh(
233
- opts: { baseUrl: string; fetchImpl?: typeof fetch },
251
+ opts: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number },
234
252
  params: AuthRefreshParams,
235
253
  ): Promise<AuthRefreshResult> {
236
254
  const baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -238,6 +256,31 @@ export async function authRefresh(
238
256
  if (!params.refreshToken?.trim()) {
239
257
  return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
240
258
  }
259
+ const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
260
+ const controller = new AbortController();
261
+ let timer: ReturnType<typeof setTimeout> | undefined;
262
+ const deadline = new Promise<AuthRefreshResult>((resolve) => {
263
+ timer = setTimeout(() => {
264
+ controller.abort(new Error("refresh request timed out"));
265
+ resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
266
+ }, timeoutMs);
267
+ });
268
+ try {
269
+ return await Promise.race([
270
+ authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
271
+ deadline,
272
+ ]);
273
+ } finally {
274
+ clearTimeout(timer);
275
+ }
276
+ }
277
+
278
+ async function authRefreshAttempt(
279
+ baseUrl: string,
280
+ fetchImpl: typeof fetch,
281
+ params: AuthRefreshParams,
282
+ signal: AbortSignal,
283
+ ): Promise<AuthRefreshResult> {
241
284
  let res: Response;
242
285
  try {
243
286
  res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
@@ -248,6 +291,8 @@ export async function authRefresh(
248
291
  "x-device-id": params.deviceId,
249
292
  },
250
293
  body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
294
+ // Still in effect while the body is read below.
295
+ signal,
251
296
  });
252
297
  } catch (err) {
253
298
  // Network error / timeout / DNS — TRANSIENT (no rotation committed).
@@ -292,8 +337,9 @@ export async function authRefresh(
292
337
  const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
293
338
  if (!accessToken || !refreshToken) {
294
339
  // Rotation succeeded server-side but the body is malformed — transient so
295
- // we retry; the next attempt will return 10003 (rotation single-use) and
296
- // escalate to permanent (§B transient→permanent).
340
+ // we retry; inside the backend grace window the retry redeems the old
341
+ // token again, after it the retry returns 10003 and escalates to
342
+ // permanent (§B transient→permanent).
297
343
  return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
298
344
  }
299
345
  return { kind: "success", accessToken, refreshToken };
@@ -639,6 +685,17 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
639
685
  `/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`,
640
686
  );
641
687
  },
688
+ async getDirectConversation(peerId): Promise<{ conversation: { id: string; type: string } }> {
689
+ assertNonBlankId(peerId, "getDirectConversation: peerId");
690
+ return await call<{ conversation: { id: string; type: string } }>(
691
+ "POST",
692
+ "/v1/conversations/direct",
693
+ {
694
+ body: JSON.stringify({ peer_id: peerId.trim() }),
695
+ headers: { "content-type": "application/json" },
696
+ },
697
+ );
698
+ },
642
699
  async getConversation(conversationId): Promise<{ conversation: ConversationDetails }> {
643
700
  return await call<{ conversation: ConversationDetails }>(
644
701
  "GET",
package/src/config.ts CHANGED
@@ -114,6 +114,8 @@ export type OpenclawClawlingAccountConfig = {
114
114
  richInteractions?: boolean;
115
115
  /** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
116
116
  awarenessNote?: boolean;
117
+ /** Speak first to a newly added non-owner friend (default true). */
118
+ friendGreeting?: boolean;
117
119
  /** Auto-install the Liveware Sample demo app when no liveware app is registered (default true). */
118
120
  livewareSample?: boolean;
119
121
  reconnect?: OpenclawClawlingReconnectConfig;
@@ -174,6 +176,7 @@ export const openclawClawlingAccountConfigSchema = {
174
176
  forwardToolCalls: { type: "boolean" },
175
177
  richInteractions: { type: "boolean" },
176
178
  awarenessNote: { type: "boolean" },
179
+ friendGreeting: { type: "boolean" },
177
180
  livewareSample: { type: "boolean" },
178
181
  reconnect: {
179
182
  type: "object",
@@ -311,6 +314,7 @@ export type ResolvedOpenclawClawlingAccount = {
311
314
  forwardToolCalls: boolean;
312
315
  richInteractions: boolean;
313
316
  awarenessNote: boolean;
317
+ friendGreeting: boolean;
314
318
  livewareSample: boolean;
315
319
  allowFrom: string[];
316
320
  reconnect: Required<OpenclawClawlingReconnectConfig>;
@@ -649,6 +653,8 @@ export function resolveOpenclawClawlingAccount(
649
653
  typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
650
654
  const awarenessNote =
651
655
  typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
656
+ const friendGreeting =
657
+ typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
652
658
  const livewareSample =
653
659
  typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
654
660
 
@@ -678,6 +684,7 @@ export function resolveOpenclawClawlingAccount(
678
684
  forwardToolCalls,
679
685
  richInteractions,
680
686
  awarenessNote,
687
+ friendGreeting,
681
688
  livewareSample,
682
689
  allowFrom: [],
683
690
  reconnect: readReconnect(channel.reconnect),
@@ -0,0 +1,91 @@
1
+ /**
2
+ * First message to a newly added NON-owner friend.
3
+ *
4
+ * `friend.added` used to be a pure awareness event. The server creates the
5
+ * direct conversation inside the friend-accept transaction, but the signal
6
+ * only carries the counterparty `usr_…` — so the runtime resolves the
7
+ * conversation through `POST /v1/conversations/direct` and then feeds ONE
8
+ * synthetic inbound turn (built here) into the normal dispatch path, the same
9
+ * way the activation bootstrap greets the owner.
10
+ *
11
+ * The prompt is deliberately distinct from the owner activation prompt: this
12
+ * reader is a stranger, so "you are connected and ready" makes no sense and
13
+ * the agent must say whose agent it is instead.
14
+ */
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { EVENT, type Envelope } from "./protocol-types.ts";
19
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
20
+
21
+ export const FRIEND_GREETING_FALLBACK = [
22
+ "A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
23
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself by name, say you are an AI agent acting on behalf of your owner, and invite them to tell you what they need.",
24
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
25
+ "Do not share your owner's private information, and do not ask the user for personal information.",
26
+ ].join("\n");
27
+
28
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
29
+ // friend without a restart. Any read failure falls back to the built-in text.
30
+ // Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
31
+ export function buildFriendGreetingText(homeDir: string = os.homedir()): string {
32
+ const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
33
+ try {
34
+ const raw = fs.readFileSync(greetingPath);
35
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
36
+ if (override.length > 0) {
37
+ return override;
38
+ }
39
+ } catch (error) {
40
+ const code = (error as NodeJS.ErrnoException).code;
41
+ if (code !== "ENOENT") {
42
+ console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
43
+ }
44
+ }
45
+ return FRIEND_GREETING_FALLBACK;
46
+ }
47
+
48
+ export interface BuildFriendGreetingEnvelopeParams {
49
+ account: ResolvedOpenclawClawlingAccount;
50
+ /** The resolved direct conversation (`cnv_…`) shared with the new friend. */
51
+ conversationId: string;
52
+ /** The new friend's `usr_…` id — becomes the sender so the turn's session and sender metadata resolve to them. */
53
+ friendUserId: string;
54
+ }
55
+
56
+ /**
57
+ * Synthetic inbound envelope for the friend greeting turn. Same invariant as
58
+ * `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
59
+ * idcode — the agent's reply inherits this chat_id, and anything else is
60
+ * refused at the outbound boundary after a full LLM turn has been spent.
61
+ */
62
+ export function buildFriendGreetingEnvelope(params: BuildFriendGreetingEnvelopeParams): Envelope {
63
+ const { account, conversationId, friendUserId } = params;
64
+ const text = buildFriendGreetingText();
65
+ const now = Date.now();
66
+ return {
67
+ version: "2",
68
+ event: EVENT.MESSAGE_SEND,
69
+ trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
70
+ emitted_at: now,
71
+ chat_id: conversationId,
72
+ chat_type: "direct",
73
+ to: { id: account.userId, type: "direct" },
74
+ sender: { id: friendUserId, type: "direct", nick_name: "" },
75
+ payload: {
76
+ message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
77
+ message_mode: "normal",
78
+ message: {
79
+ body: { fragments: [{ kind: "text", text }] },
80
+ context: { mentions: [], reply: null },
81
+ streaming: {
82
+ status: "static",
83
+ sequence: 0,
84
+ mutation_policy: "sealed",
85
+ started_at: null,
86
+ completed_at: null,
87
+ },
88
+ },
89
+ },
90
+ } as unknown as Envelope;
91
+ }
@@ -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
+ }