@clawling/clawchat-plugin-openclaw 2026.9.16-1 → 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.
@@ -1,7 +1,7 @@
1
1
  import { ClawlingApiError, } from "./api-types.js";
2
2
  import { CHANNEL_ID } from "./config.js";
3
3
  export function buildPluginReportBody(input) {
4
- return {
4
+ const body = {
5
5
  device_id: input.deviceId,
6
6
  platform: input.platform,
7
7
  plugin_version: input.pluginVersion,
@@ -9,6 +9,16 @@ export function buildPluginReportBody(input) {
9
9
  runtime_name: input.runtimeName,
10
10
  runtime_version: input.runtimeVersion,
11
11
  };
12
+ const ob = input.onboarding;
13
+ if (ob?.wiki_report_id !== undefined)
14
+ body.wiki_report_id = ob.wiki_report_id;
15
+ if (ob?.capability_tier !== undefined)
16
+ body.capability_tier = ob.capability_tier;
17
+ if (ob?.capability_ceiling !== undefined)
18
+ body.capability_ceiling = ob.capability_ceiling;
19
+ if (ob?.capabilities !== undefined)
20
+ body.capabilities = ob.capabilities;
21
+ return body;
12
22
  }
13
23
  /**
14
24
  * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
@@ -472,7 +482,24 @@ export function createOpenclawClawlingApiClient(opts) {
472
482
  headers: { "content-type": "application/json" },
473
483
  });
474
484
  },
475
- async agentsConnect({ code: inviteCode, platform, type, user_id: userId }) {
485
+ async agentsConnectCheck({ code, platform, user_id: userId, context }) {
486
+ if (!code?.trim()) {
487
+ throw new ClawlingApiError("validation", "agentsConnectCheck: code is required");
488
+ }
489
+ const body = { code: code.trim(), platform: platform.trim() };
490
+ if (userId?.trim())
491
+ body.user_id = userId.trim();
492
+ if (opts.pluginVersion?.trim())
493
+ body.plugin_version = opts.pluginVersion.trim();
494
+ for (const [k, v] of Object.entries(context ?? {}))
495
+ if (v)
496
+ body[k] = v;
497
+ return await call("POST", "/v1/agents/connect/check", {
498
+ headers: { "content-type": "application/json" },
499
+ body: JSON.stringify(body),
500
+ });
501
+ },
502
+ async agentsConnect({ code: inviteCode, platform, type, user_id: userId, context }) {
476
503
  if (!inviteCode?.trim()) {
477
504
  throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
478
505
  }
@@ -493,6 +520,9 @@ export function createOpenclawClawlingApiClient(opts) {
493
520
  if (opts.pluginVersion?.trim()) {
494
521
  body.plugin_version = opts.pluginVersion.trim();
495
522
  }
523
+ for (const [k, v] of Object.entries(context ?? {}))
524
+ if (v)
525
+ body[k] = v;
496
526
  return await call("POST", "/v1/agents/connect", {
497
527
  // `X-Device-Id` is added globally via `authHeaders` on every request.
498
528
  headers: { "content-type": "application/json" },
@@ -0,0 +1,74 @@
1
+ import { ClawlingApiError } from "./api-types.js";
2
+ import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
3
+ /**
4
+ * Non-consuming pre-check of a connect code. Records the "checked" funnel
5
+ * stage server-side and tells us whether the code is bound to an existing
6
+ * agent (the reconnect prompt). Any transport-level failure — older backend,
7
+ * rate limit, network — degrades to `null`: the pre-check is telemetry plus a
8
+ * courtesy, never a gate in front of `/connect`.
9
+ */
10
+ export async function preCheckConnectCode(client, input, log) {
11
+ try {
12
+ const res = await client.agentsConnectCheck({
13
+ code: input.code,
14
+ platform: AGENTS_CONNECT_PLATFORM,
15
+ ...(input.userId ? { user_id: input.userId } : {}),
16
+ ...(input.context ? { context: input.context } : {}),
17
+ });
18
+ // A 200 with no data says nothing about the code: degrade exactly like an
19
+ // unreachable endpoint (Hermes' `evaluate_precheck({})` does the same).
20
+ if (!res || typeof res !== "object" || Object.keys(res).length === 0) {
21
+ log("Connect-code pre-check returned no data; continuing without it.");
22
+ return null;
23
+ }
24
+ return {
25
+ pairable: res.pairable === true,
26
+ status: typeof res.status === "string" ? res.status : "",
27
+ boundAgent: res.bound_agent === true,
28
+ userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
29
+ };
30
+ }
31
+ catch (err) {
32
+ const kind = err instanceof ClawlingApiError ? err.kind : "error";
33
+ log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
34
+ return null;
35
+ }
36
+ }
37
+ /** Owner-facing explanation for `pairable: false`. No flags, no minutes, one URL. */
38
+ export function unpairableMessage(pre) {
39
+ if (pre.userIdStatus === "owner_mismatch" && pre.boundAgent) {
40
+ // The server cannot tell us whether the bound agent shares this identity's
41
+ // owner, only that it is a different agent — so say exactly that.
42
+ return ("This connect code is the reconnect prompt for a different agent than the identity stored here. " +
43
+ "Ask your owner to send the reconnect prompt from THIS agent's chat in the ClawChat app.");
44
+ }
45
+ if (pre.userIdStatus === "invalid") {
46
+ return ("The identity stored here is not a valid ClawChat user id, so it cannot be restored. " +
47
+ "Activate as a brand-new agent with --new-account.");
48
+ }
49
+ if (pre.userIdStatus === "owner_mismatch") {
50
+ return ("This connect code belongs to a different ClawChat account than the identity stored here. " +
51
+ "Ask the owner of THIS agent for a code, or activate as a brand-new agent with --new-account.");
52
+ }
53
+ switch (pre.status) {
54
+ case "paired":
55
+ return ("This connect code was already redeemed. If this agent lost its connection, ask your owner " +
56
+ `to send you the reconnect prompt from the ClawChat app and follow ${RECONNECT_GUIDE_URL}; ` +
57
+ "otherwise ask for a fresh code.");
58
+ case "expired":
59
+ case "invalid":
60
+ return `This connect code is ${pre.status}. Ask your owner for a fresh code from the ClawChat app.`;
61
+ default:
62
+ return `This connect code is not pairable (status=${pre.status || "unknown"}). Ask your owner for a fresh code from the ClawChat app.`;
63
+ }
64
+ }
65
+ /**
66
+ * Refusal for a new-identity intent (`--new-account` or the interactive
67
+ * choice) on a bound code: without a user_id, `/connect` restores the bound
68
+ * agent instead of creating one. No flags, no minutes, no URL.
69
+ */
70
+ export function boundCodeNewIdentityMessage() {
71
+ return ("This is a reconnect code bound to an existing agent, so it cannot create a new agent. " +
72
+ "Ask your owner for a normal connect code from the ClawChat app, or use this reconnect prompt " +
73
+ "on the agent it belongs to.");
74
+ }
@@ -6,23 +6,12 @@ import { openclawClawlingAccountSlotPath, patchOpenclawClawlingAccountSection }
6
6
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
7
7
  import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, } from "./config.js";
8
8
  import { getClawChatStore } from "./storage.js";
9
- /**
10
- * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
11
- * agent runtime — openclaw's bundled clawchat channel.
12
- */
13
- export const AGENTS_CONNECT_PLATFORM = "openclaw";
14
- /**
15
- * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
16
- * always a bot; humans don't log in through this flow.
17
- */
18
- export const AGENTS_CONNECT_TYPE = "clawbot";
19
- /**
20
- * `/v1/agents/connect` envelope code for "the supplied user_id matches no
21
- * agent". Servers that predate the server-side stale-user_id fallback return
22
- * this instead of degrading to a fresh pairing, so the client sheds the id and
23
- * retries once.
24
- */
25
- export const AGENT_NOT_FOUND_CODE = 16001;
9
+ import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
10
+ import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
11
+ // Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
12
+ // from here, so the historical import path keeps working after the move.
13
+ import { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE, } from "./onboarding-context.js";
14
+ export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
26
15
  /**
27
16
  * Thrown when redeeming an invite code would silently replace a live activation.
28
17
  *
@@ -43,9 +32,11 @@ export class ExistingActivationError extends Error {
43
32
  "spent. Re-run stating the intent:\n" +
44
33
  " - Pair as a BRAND-NEW agent (this instance stops using the identity " +
45
34
  "above): /clawchat-activate <CODE> --new-account\n" +
46
- " - RESTORE the identity above (re-pairs that same agent; if it was " +
47
- "deleted this brings it back with its history): " +
48
- "/clawchat-activate <CODE> --repair\n" +
35
+ " - RESTORE the identity above: do not spend a fresh code on it. Ask your " +
36
+ "owner to send you the reconnect prompt from the ClawChat app — its code is " +
37
+ "bound to this identity and usually restores it on its own; if activation " +
38
+ "still reports it as already paired, run it again with --repair " +
39
+ `(/clawchat-activate <CODE> --repair) — and follow ${RECONNECT_GUIDE_URL}\n` +
49
40
  " - To run a SECOND agent alongside this one instead, activate it as a " +
50
41
  "named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
51
42
  "channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)");
@@ -197,7 +188,50 @@ export async function runOpenclawClawlingLogin(params) {
197
188
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
198
189
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
199
190
  const account = resolveOpenclawClawlingAccount(cfg, accountId);
200
- // Fork before the invite code is even read, so neither branch spends the code
191
+ const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
192
+ if (!inviteCode) {
193
+ throw new Error("Login aborted: invite code is required.");
194
+ }
195
+ const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
196
+ baseUrl: account.baseUrl,
197
+ mediaBaseUrl: account.mediaBaseUrl,
198
+ // Pre-login we may not have a token yet. Send the current one (or empty)
199
+ // — the server should accept an unauthenticated invite-code exchange.
200
+ token: account.token || "",
201
+ pluginVersion: resolvePluginVersion(),
202
+ });
203
+ // Telemetry the backend joins with wiki field reports; ignored by older servers.
204
+ const onboarding = buildOnboardingContext();
205
+ // Non-consuming pre-check. `null` = endpoint unavailable, carry on. A bound
206
+ // code (the owner's reconnect prompt) is the owner's own proof of intent, so
207
+ // it settles the "new agent or restore?" question below without a flag.
208
+ //
209
+ // The pre-check only carries the `user_id` that `/connect` would replay. An
210
+ // explicit `newAccount` never replays it, so it must not be judged against
211
+ // it either: the server marks `pairable:false` for an `owner_mismatch` /
212
+ // `invalid` id, and that would block the very escape the refusal recommends.
213
+ const storedUserId = account.userId.trim();
214
+ const precheckUserId = params.newAccount ? "" : storedUserId;
215
+ runtime.log("Checking the invite code …");
216
+ let precheck = await preCheckConnectCode(apiClient, { code: inviteCode, userId: precheckUserId || undefined, context: onboarding }, runtime.log);
217
+ // Whether the operator will be asked "new agent or restore?" below. When
218
+ // they will, a refusal caused only by the stored identity (not by the code)
219
+ // is deferred: choosing a brand-new identity drops that id, so the verdict
220
+ // on it no longer applies.
221
+ const identityUnsettled = storedUserId !== "" && account.token.trim() !== "" && !params.newAccount && !params.repair;
222
+ const refusedForIdentity = precheck !== null &&
223
+ !precheck.pairable &&
224
+ precheckUserId !== "" &&
225
+ // Only a live code: a dead one (expired / paired / invalid) refuses on its
226
+ // own account whatever identity is chosen, so there is nothing to defer.
227
+ precheck.status === "pending" &&
228
+ (precheck.userIdStatus === "owner_mismatch" || precheck.userIdStatus === "invalid");
229
+ const deferredRefusal = identityUnsettled && refusedForIdentity ? precheck : null;
230
+ if (precheck && !precheck.pairable && !deferredRefusal) {
231
+ throw new Error(unpairableMessage(precheck));
232
+ }
233
+ const boundToIncumbent = precheck?.pairable === true && precheck.boundAgent === true && precheckUserId !== "";
234
+ // Fork before the invite code is spent, so neither branch spends the code
201
235
  // before the outcome is settled. "Live" is decided by whether a usable token
202
236
  // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
203
237
  // logged-out instance still re-pairs with no flag at all, which is the flow
@@ -207,46 +241,56 @@ export async function runOpenclawClawlingLogin(params) {
207
241
  // ambiguous, because redeeming a code here can either mint a new agent or
208
242
  // re-pair the incumbent one. Ask which, and only refuse when nobody answers.
209
243
  // An explicit `newAccount` / `repair` from the caller has already settled it,
210
- // so no prompt.
244
+ // so no prompt. Nor is one needed when the pre-check proved the code is the
245
+ // owner's own reconnect prompt for this very agent (`boundToIncumbent`) — a
246
+ // bound code can only ever restore the incumbent identity.
211
247
  let newAccount = params.newAccount;
212
- if (account.userId.trim() && account.token.trim() && !newAccount && !params.repair) {
248
+ if (identityUnsettled && !boundToIncumbent) {
213
249
  const identity = account.agentId.trim()
214
- ? `agent ${account.agentId.trim()} (shadow user ${account.userId.trim()})`
215
- : `agent ${account.userId.trim()}`;
250
+ ? `agent ${account.agentId.trim()} (shadow user ${storedUserId})`
251
+ : `agent ${storedUserId}`;
216
252
  const intent = await (params.readActivationIntent ??
217
253
  (() => promptActivationIntentFromStdin(runtime, identity)))();
218
254
  // Only "new-account" changes what gets sent. Restoring needs no flag:
219
255
  // replaying the stored `user_id` IS the re-pair, and that is already the
220
256
  // default below — so choosing it simply means "don't refuse".
221
- if (intent === "new-account")
257
+ if (intent === "new-account") {
222
258
  newAccount = true;
259
+ if (deferredRefusal) {
260
+ // The first verdict judged the id we are now dropping; re-check the
261
+ // code on its own before spending it.
262
+ precheck = await preCheckConnectCode(apiClient, { code: inviteCode, context: onboarding }, runtime.log);
263
+ if (precheck && !precheck.pairable) {
264
+ throw new Error(unpairableMessage(precheck));
265
+ }
266
+ }
267
+ }
268
+ else if (deferredRefusal) {
269
+ // Restoring (or nobody to ask) replays the id the server already refused.
270
+ throw new Error(unpairableMessage(deferredRefusal));
271
+ }
223
272
  else if (intent === null) {
224
- throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
273
+ throw new ExistingActivationError(storedUserId, account.agentId.trim());
225
274
  }
226
275
  }
227
- const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
228
- if (!inviteCode) {
229
- throw new Error("Login aborted: invite code is required.");
276
+ // A new identity (flag or interactive choice) sends no user_id, and /connect
277
+ // without one on a bound code silently RESTORES the bound agent and spends
278
+ // the code. A reconnect code can never mint an agent, so refuse first.
279
+ if (newAccount && precheck?.boundAgent === true) {
280
+ throw new Error(boundCodeNewIdentityMessage());
230
281
  }
231
- const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
232
- baseUrl: account.baseUrl,
233
- mediaBaseUrl: account.mediaBaseUrl,
234
- // Pre-login we may not have a token yet. Send the current one (or empty)
235
- // — the server should accept an unauthenticated invite-code exchange.
236
- token: account.token || "",
237
- pluginVersion: resolvePluginVersion(),
238
- });
239
282
  runtime.log("Verifying invite code …");
240
283
  let result;
241
284
  // A brand-new identity is precisely "do not replay" — the replay is the only
242
285
  // thing that would bind this code to the incumbent agent.
243
- const existingUserId = newAccount ? "" : account.userId.trim();
286
+ const existingUserId = newAccount ? "" : storedUserId;
244
287
  try {
245
288
  result = await apiClient.agentsConnect({
246
289
  code: inviteCode,
247
290
  platform: AGENTS_CONNECT_PLATFORM,
248
291
  type: AGENTS_CONNECT_TYPE,
249
292
  ...(existingUserId ? { user_id: existingUserId } : {}),
293
+ context: onboarding,
250
294
  });
251
295
  }
252
296
  catch (err) {
@@ -266,6 +310,7 @@ export async function runOpenclawClawlingLogin(params) {
266
310
  code: inviteCode,
267
311
  platform: AGENTS_CONNECT_PLATFORM,
268
312
  type: AGENTS_CONNECT_TYPE,
313
+ context: onboarding,
269
314
  });
270
315
  }
271
316
  catch (retryErr) {
@@ -0,0 +1,70 @@
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
+ export const AGENT_KIND = "openclaw";
13
+ export const LANE = "self";
14
+ export const MATCHED_INSTALL = "install/official-openclaw";
15
+ /** Public connection wiki. `/start.md` is the entry page; `/reconnect.md` the re-pair page. */
16
+ export const START_GUIDE_URL = "https://agent-connection.clawling.com/start.md";
17
+ export const RECONNECT_GUIDE_URL = "https://agent-connection.clawling.com/reconnect.md";
18
+ const WIKI_VERSION_MAX = 40;
19
+ /** Wiki `os` enum (windows | macos | linux); "" for anything else so callers omit it. */
20
+ export function hostOs(plat = process.platform) {
21
+ switch (plat) {
22
+ case "win32":
23
+ return "windows";
24
+ case "darwin":
25
+ return "macos";
26
+ case "linux":
27
+ return "linux";
28
+ default:
29
+ return "";
30
+ }
31
+ }
32
+ /**
33
+ * The wiki build stamp the agent saw on the install page. The official install
34
+ * page tells the agent to export `CLAWCHAT_WIKI_VERSION` before activating; a
35
+ * hand-run `openclaw channels add` simply has none.
36
+ */
37
+ export function wikiVersion(env = process.env) {
38
+ return (env.CLAWCHAT_WIKI_VERSION ?? "").trim().slice(0, WIKI_VERSION_MAX);
39
+ }
40
+ export function buildOnboardingContext(opts = {}) {
41
+ const out = {
42
+ agent_kind: AGENT_KIND,
43
+ lane: LANE,
44
+ matched_install: MATCHED_INSTALL,
45
+ };
46
+ const os = hostOs(opts.platform);
47
+ if (os)
48
+ out.os = os;
49
+ const wiki = wikiVersion(opts.env);
50
+ if (wiki)
51
+ out.wiki_version = wiki;
52
+ return out;
53
+ }
54
+ /**
55
+ * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
56
+ * agent runtime — openclaw's bundled clawchat channel.
57
+ */
58
+ export const AGENTS_CONNECT_PLATFORM = "openclaw";
59
+ /**
60
+ * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
61
+ * always a bot; humans don't log in through this flow.
62
+ */
63
+ export const AGENTS_CONNECT_TYPE = "clawbot";
64
+ /**
65
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
66
+ * agent". Servers that predate the server-side stale-user_id fallback return
67
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
68
+ * retries once.
69
+ */
70
+ export const AGENT_NOT_FOUND_CODE = 16001;
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const REPORT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
5
+ const CAP_KEYS = ["headless", "mcp", "permission_hook", "session_line"];
6
+ function tier(v) {
7
+ return Number.isInteger(v) && v >= 1 && v <= 4 ? v : undefined;
8
+ }
9
+ /**
10
+ * Read + validate `~/clawchat/onboarding.json`. Never throws: an unresolvable
11
+ * home directory, an absent/unreadable file, or malformed JSON all fall
12
+ * through to `null`, same as a file with no valid fields — this must be safe
13
+ * to call unconditionally from a best-effort report path.
14
+ */
15
+ export function readOnboardingReport(homeDir) {
16
+ try {
17
+ const base = homeDir ?? os.homedir();
18
+ const file = path.join(base, "clawchat", "onboarding.json");
19
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
20
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
21
+ return null;
22
+ const o = raw;
23
+ const out = {};
24
+ if (typeof o.wiki_report_id === "string" && REPORT_ID_RE.test(o.wiki_report_id))
25
+ out.wiki_report_id = o.wiki_report_id;
26
+ const t = tier(o.capability_tier);
27
+ if (t !== undefined)
28
+ out.capability_tier = t;
29
+ const c = tier(o.capability_ceiling);
30
+ // The backend rejects the WHOLE report (22004) when ceiling < tier, which
31
+ // would silently stop the version row updating. Keep the tier, drop the
32
+ // inconsistent ceiling.
33
+ if (c !== undefined && (t === undefined || c >= t))
34
+ out.capability_ceiling = c;
35
+ if (o.capabilities && typeof o.capabilities === "object") {
36
+ const caps = {};
37
+ for (const k of CAP_KEYS) {
38
+ const v = o.capabilities[k];
39
+ if (typeof v === "boolean")
40
+ caps[k] = v;
41
+ }
42
+ if (Object.keys(caps).length > 0)
43
+ out.capabilities = caps;
44
+ }
45
+ return Object.keys(out).length > 0 ? out : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
@@ -56,6 +56,7 @@ export async function reportPluginVersionSafe(p) {
56
56
  agentVersion: p.agentVersion,
57
57
  runtimeName: "node",
58
58
  runtimeVersion: process.version,
59
+ onboarding: p.onboarding ?? null,
59
60
  }, { authenticated: p.authenticated });
60
61
  }
61
62
  catch (err) {
@@ -7,10 +7,12 @@ import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { buildActivationBootstrapText } from "./activation-greeting.js";
8
8
  import { buildFriendGreetingEnvelope } from "./friend-greeting.js";
9
9
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
10
+ import { readOnboardingReport } from "./onboarding-report.js";
10
11
  import { ensureLivewareCli, livewareCliHomeDir, livewareSampleRootDir, resolveLivewarePath, } from "./liveware-cli.js";
11
12
  import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
13
  import { ClawlingApiError } from "./api-types.js";
13
14
  import { RefreshManager } from "./refresh-manager.js";
15
+ import { RECONNECT_GUIDE_URL } from "./onboarding-context.js";
14
16
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
15
17
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, CLAWCHAT_REFRESH_TOKEN_ENV, } from "./config.js";
16
18
  import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
@@ -102,8 +104,12 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
102
104
  ]);
103
105
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
104
106
  // §C.1 — user-visible message emitted on permanent token expiry. Kept
105
- // byte-identical to the Hermes plugin (parity spec §C.1.4).
106
- const CLAWCHAT_TOKEN_EXPIRED_MESSAGE = "ClawChat token expired and could not be refreshed. Re-pair with `/clawchat-activate <code>`.";
107
+ // byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
108
+ // at the reconnect prompt (a code bound to this identity) rather than at a
109
+ // slash command: a fresh create code spent here would mint a second agent.
110
+ export const CLAWCHAT_TOKEN_EXPIRED_MESSAGE = "ClawChat token expired and could not be refreshed. Ask your owner to send you the reconnect prompt from the ClawChat app, then follow " +
111
+ RECONNECT_GUIDE_URL +
112
+ ".";
107
113
  const CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR = "token expired — re-pair required";
108
114
  function isRecord(value) {
109
115
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -724,6 +730,7 @@ export async function startOpenclawClawlingGateway(params) {
724
730
  pluginVersion,
725
731
  agentVersion,
726
732
  authenticated: false,
733
+ onboarding: readOnboardingReport(),
727
734
  log,
728
735
  });
729
736
  // Ensure the liveware CLI is installed (async, never blocks startup).
@@ -796,6 +803,7 @@ export async function startOpenclawClawlingGateway(params) {
796
803
  pluginVersion,
797
804
  agentVersion,
798
805
  authenticated: true,
806
+ onboarding: readOnboardingReport(),
799
807
  log,
800
808
  });
801
809
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
66
66
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
67
67
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
68
68
  */
69
- export const DEFAULT_SKILLS_REF = "skills-v1.9.0";
69
+ export const DEFAULT_SKILLS_REF = "skills-v1.10.0";
70
70
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
71
71
  export const MAX_SKILL_BYTES = 256 * 1024;
72
72
  /** This adapter's host target inside `skills/manifest.json`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.9.16-1",
3
+ "version": "2026.9.17-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clawchat-core
3
- version: 1.3.0
3
+ version: 1.4.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
 
@@ -44,7 +44,14 @@ If `channels add` reports `Unknown channel: clawchat-plugin-openclaw`, use the r
44
44
 
45
45
  ### What a connect code is
46
46
 
47
- A connect code is issued by the owner inside the ClawChat app (注册 Agent → OpenClaw, or a service's 创建新身份 page). It is a short single-use string that expires on its own. Use it **exactly as provided** — do not lowercase, normalize, add prefixes, invent, reuse, or retry a code, and never assume a length or a digits-only shape: an older note here called it a six-digit "invite code"; that was the legacy pairing flow and no longer holds. If activation fails with a non-zero exit or API error, report the error verbatim and ask for a fresh code.
47
+ A connect code is issued by the owner in the ClawChat app's add-agent card and
48
+ usually reaches you inside the connection prompt the app produces, which points
49
+ you at https://agent-connection.clawling.com/start.md. It is a short single-use
50
+ string that expires on its own. Use it **exactly as provided** — do not lowercase,
51
+ normalize, add prefixes, invent, reuse, or retry a code, and never assume a length
52
+ or a digits-only shape: an older note here called it a six-digit "invite code";
53
+ that was the legacy pairing flow and no longer holds. If activation fails with a
54
+ non-zero exit or API error, report the error verbatim and ask for a fresh code.
48
55
 
49
56
  ### When this OpenClaw is already connected
50
57
 
@@ -57,6 +64,27 @@ A fresh connect code always means **one more identity**. If the owner hands you
57
64
 
58
65
  Never pick a flag by matching words in an error message, and never re-run activation with a flag you chose to get past one.
59
66
 
67
+ ### Lost the connection (token expired, auto-logout, or an "already paired" refusal)
68
+
69
+ Do not spend a fresh create code on the identity you already hold — that mints a
70
+ second agent. Ask your owner to send you the **reconnect prompt** from the ClawChat
71
+ app (the agent's own page) and follow
72
+ https://agent-connection.clawling.com/reconnect.md. The code in that prompt is
73
+ bound to your identity: activation usually restores it on its own, and if it
74
+ still reports the identity as already paired, run it again with `--repair`
75
+ (`/clawchat-activate CODE --repair`).
76
+
77
+ ### After onboarding: the field report
78
+
79
+ If your owner sent you here from the ClawChat app, file the structured field
80
+ report described in https://agent-connection.clawling.com/start.md Appendix B once
81
+ you have exchanged one real message with your owner in both directions. Keep the
82
+ returned `id` and write it to `~/clawchat/onboarding.json` as
83
+ `{"wiki_report_id": "<id>"}` (plain JSON, no other keys required); the plugin
84
+ forwards it to ClawChat on its next connection so the owner's app can show that
85
+ the report exists. Never put a ClawChat user, agent, or conversation id in the
86
+ report itself.
87
+
60
88
  ## Output Visibility
61
89
 
62
90
  When the user asks to change ClawChat output verbosity, use the runtime slash command for the current conversation. Treat natural-language wording as aliases for the three supported modes:
@@ -3,10 +3,10 @@
3
3
  "skills": {
4
4
  "openclaw": {
5
5
  "clawchat-core": {
6
- "version": "1.3.0",
6
+ "version": "1.4.0",
7
7
  "path": "openclaw/clawchat-core/SKILL.md",
8
- "sha256": "38468122ef085f652fbcffd6241803019e0242086d2a9c039152591c8d306976",
9
- "bytes": 11650
8
+ "sha256": "c0cd2a83d6b48b6f4778a2127b5f4f7dd1ba33ea093972e714eee4bc0af6ed67",
9
+ "bytes": 12893
10
10
  },
11
11
  "clawchat-liveware": {
12
12
  "version": "1.2.2",
@@ -35,10 +35,10 @@
35
35
  },
36
36
  "hermes": {
37
37
  "clawchat-core": {
38
- "version": "1.9.0",
38
+ "version": "1.10.0",
39
39
  "path": "hermes/clawchat-core/SKILL.md",
40
- "sha256": "fd00ff36b58385c774a9268229136523a73a16c258d9af046aaa5bfa7d30a90f",
41
- "bytes": 18632
40
+ "sha256": "6130e98e426f5d51392fd5bd7d75932d859b0531ea4216ef9a0a50526b99dfb6",
41
+ "bytes": 20124
42
42
  },
43
43
  "clawchat-liveware": {
44
44
  "version": "1.2.2",
package/src/api-client.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  ClawlingApiError,
3
3
  type AgentAppView,
4
+ type AgentConnectCheckInput,
5
+ type AgentConnectCheckResult,
4
6
  type AgentMetadataPatch,
5
7
  type AgentProfile,
6
8
  type AgentConnectResult,
@@ -17,9 +19,24 @@ import {
17
19
  type UserSearchHit,
18
20
  } from "./api-types.ts";
19
21
  import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
22
+ import type { OnboardingReportFields } from "./onboarding-report.ts";
20
23
  import type { PermissionPolicy, PermState } from "./permissions.ts";
21
24
  import { CHANNEL_ID } from "./config.ts";
22
25
 
26
+ /** Params for `agentsConnect` — exchange an invite code for an agent token. */
27
+ export interface AgentsConnectInput {
28
+ /** The invite code entered by the operator. */
29
+ code: string;
30
+ /** Platform the agent is attaching from (e.g. "openclaw"). */
31
+ platform: string;
32
+ /** Agent type tag (e.g. "bot"). */
33
+ type: string;
34
+ /** Existing configured ClawChat user id, when re-activating an account. */
35
+ user_id?: string;
36
+ /** agent_kind / os / lane / wiki_version / matched_install — see onboarding-context.ts */
37
+ context?: Record<string, string>;
38
+ }
39
+
23
40
  export interface PluginReportInput {
24
41
  deviceId: string;
25
42
  platform: string;
@@ -27,10 +44,12 @@ export interface PluginReportInput {
27
44
  agentVersion: string;
28
45
  runtimeName: string;
29
46
  runtimeVersion: string;
47
+ /** Agent-written onboarding facts (`~/clawchat/onboarding.json`); optional. */
48
+ onboarding?: OnboardingReportFields | null;
30
49
  }
31
50
 
32
- export function buildPluginReportBody(input: PluginReportInput): Record<string, string> {
33
- return {
51
+ export function buildPluginReportBody(input: PluginReportInput): Record<string, unknown> {
52
+ const body: Record<string, unknown> = {
34
53
  device_id: input.deviceId,
35
54
  platform: input.platform,
36
55
  plugin_version: input.pluginVersion,
@@ -38,6 +57,12 @@ export function buildPluginReportBody(input: PluginReportInput): Record<string,
38
57
  runtime_name: input.runtimeName,
39
58
  runtime_version: input.runtimeVersion,
40
59
  };
60
+ const ob = input.onboarding;
61
+ if (ob?.wiki_report_id !== undefined) body.wiki_report_id = ob.wiki_report_id;
62
+ if (ob?.capability_tier !== undefined) body.capability_tier = ob.capability_tier;
63
+ if (ob?.capability_ceiling !== undefined) body.capability_ceiling = ob.capability_ceiling;
64
+ if (ob?.capabilities !== undefined) body.capabilities = ob.capabilities;
65
+ return body;
41
66
  }
42
67
 
43
68
  export interface ApiClientOptions {
@@ -149,19 +174,16 @@ export interface OpenclawClawlingApiClient {
149
174
  uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
150
175
  /**
151
176
  * Exchange an invite code for an agent token.
152
- * Request body shape: `{ code, platform, type, user_id?, plugin_version? }`.
153
- * `plugin_version` is included when `ApiClientOptions.pluginVersion` is set.
177
+ * Request body shape: `{ code, platform, type, user_id?, plugin_version?, agent_kind?, os?, lane?, wiki_version?, matched_install? }`.
178
+ * `plugin_version` is included when `ApiClientOptions.pluginVersion` is set;
179
+ * `context` is flattened onto the top-level body (see `onboarding-context.ts`).
180
+ */
181
+ agentsConnect(params: AgentsConnectInput): Promise<AgentConnectResult>;
182
+ /**
183
+ * POST /v1/agents/connect/check — non-consuming pairability pre-check. Same
184
+ * `X-Device-Id` as `agentsConnect` so the funnel row is keyed identically.
154
185
  */
155
- agentsConnect(params: {
156
- /** The invite code entered by the operator. */
157
- code: string;
158
- /** Platform the agent is attaching from (e.g. "openclaw"). */
159
- platform: string;
160
- /** Agent type tag (e.g. "bot"). */
161
- type: string;
162
- /** Existing configured ClawChat user id, when re-activating an account. */
163
- user_id?: string;
164
- }): Promise<AgentConnectResult>;
186
+ agentsConnectCheck(input: AgentConnectCheckInput): Promise<AgentConnectCheckResult>;
165
187
  /**
166
188
  * Upload an avatar image via `POST /v1/files/upload-url`. The resulting
167
189
  * `url` is what you then pass to `updateMyProfile({ avatar_url: url })`.
@@ -755,7 +777,20 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
755
777
  },
756
778
  );
757
779
  },
758
- async agentsConnect({ code: inviteCode, platform, type, user_id: userId }): Promise<AgentConnectResult> {
780
+ async agentsConnectCheck({ code, platform, user_id: userId, context }): Promise<AgentConnectCheckResult> {
781
+ if (!code?.trim()) {
782
+ throw new ClawlingApiError("validation", "agentsConnectCheck: code is required");
783
+ }
784
+ const body: Record<string, string> = { code: code.trim(), platform: platform.trim() };
785
+ if (userId?.trim()) body.user_id = userId.trim();
786
+ if (opts.pluginVersion?.trim()) body.plugin_version = opts.pluginVersion.trim();
787
+ for (const [k, v] of Object.entries(context ?? {})) if (v) body[k] = v;
788
+ return await call<AgentConnectCheckResult>("POST", "/v1/agents/connect/check", {
789
+ headers: { "content-type": "application/json" },
790
+ body: JSON.stringify(body),
791
+ });
792
+ },
793
+ async agentsConnect({ code: inviteCode, platform, type, user_id: userId, context }): Promise<AgentConnectResult> {
759
794
  if (!inviteCode?.trim()) {
760
795
  throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
761
796
  }
@@ -776,6 +811,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
776
811
  if (opts.pluginVersion?.trim()) {
777
812
  body.plugin_version = opts.pluginVersion.trim();
778
813
  }
814
+ for (const [k, v] of Object.entries(context ?? {})) if (v) body[k] = v;
779
815
  return await call<AgentConnectResult>("POST", "/v1/agents/connect", {
780
816
  // `X-Device-Id` is added globally via `authHeaders` on every request.
781
817
  headers: { "content-type": "application/json" },
package/src/api-types.ts CHANGED
@@ -146,6 +146,24 @@ export interface AgentConnectResult {
146
146
  conversation?: AgentConnectConversation;
147
147
  }
148
148
 
149
+ /** Body of POST /v1/agents/connect/check. Same optional telemetry as /connect. */
150
+ export interface AgentConnectCheckInput {
151
+ code: string;
152
+ platform: string;
153
+ user_id?: string;
154
+ /** agent_kind / os / lane / wiki_version / matched_install — see onboarding-context.ts */
155
+ context?: Record<string, string>;
156
+ }
157
+
158
+ /** data of POST /v1/agents/connect/check. Older backends omit `bound_agent`. */
159
+ export interface AgentConnectCheckResult {
160
+ pairable: boolean;
161
+ status: string;
162
+ expires_at?: string;
163
+ user_id_status?: string;
164
+ bound_agent?: boolean;
165
+ }
166
+
149
167
  export type AgentAppView = { id: string; app_id?: string; name: string; url: string };
150
168
 
151
169
  export type ClawlingApiErrorKind =
@@ -0,0 +1,98 @@
1
+ import { ClawlingApiError } from "./api-types.ts";
2
+ import type { OpenclawClawlingApiClient } from "./api-client.ts";
3
+ import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
4
+
5
+ export interface ConnectPreCheck {
6
+ pairable: boolean;
7
+ status: string;
8
+ boundAgent: boolean;
9
+ userIdStatus: string;
10
+ }
11
+
12
+ /**
13
+ * Non-consuming pre-check of a connect code. Records the "checked" funnel
14
+ * stage server-side and tells us whether the code is bound to an existing
15
+ * agent (the reconnect prompt). Any transport-level failure — older backend,
16
+ * rate limit, network — degrades to `null`: the pre-check is telemetry plus a
17
+ * courtesy, never a gate in front of `/connect`.
18
+ */
19
+ export async function preCheckConnectCode(
20
+ client: Pick<OpenclawClawlingApiClient, "agentsConnectCheck">,
21
+ input: { code: string; userId?: string; context?: Record<string, string> },
22
+ log: (message: string) => void,
23
+ ): Promise<ConnectPreCheck | null> {
24
+ try {
25
+ const res = await client.agentsConnectCheck({
26
+ code: input.code,
27
+ platform: AGENTS_CONNECT_PLATFORM,
28
+ ...(input.userId ? { user_id: input.userId } : {}),
29
+ ...(input.context ? { context: input.context } : {}),
30
+ });
31
+ // A 200 with no data says nothing about the code: degrade exactly like an
32
+ // unreachable endpoint (Hermes' `evaluate_precheck({})` does the same).
33
+ if (!res || typeof res !== "object" || Object.keys(res).length === 0) {
34
+ log("Connect-code pre-check returned no data; continuing without it.");
35
+ return null;
36
+ }
37
+ return {
38
+ pairable: res.pairable === true,
39
+ status: typeof res.status === "string" ? res.status : "",
40
+ boundAgent: res.bound_agent === true,
41
+ userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
42
+ };
43
+ } catch (err) {
44
+ const kind = err instanceof ClawlingApiError ? err.kind : "error";
45
+ log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Owner-facing explanation for `pairable: false`. No flags, no minutes, one URL. */
51
+ export function unpairableMessage(pre: ConnectPreCheck): string {
52
+ if (pre.userIdStatus === "owner_mismatch" && pre.boundAgent) {
53
+ // The server cannot tell us whether the bound agent shares this identity's
54
+ // owner, only that it is a different agent — so say exactly that.
55
+ return (
56
+ "This connect code is the reconnect prompt for a different agent than the identity stored here. " +
57
+ "Ask your owner to send the reconnect prompt from THIS agent's chat in the ClawChat app."
58
+ );
59
+ }
60
+ if (pre.userIdStatus === "invalid") {
61
+ return (
62
+ "The identity stored here is not a valid ClawChat user id, so it cannot be restored. " +
63
+ "Activate as a brand-new agent with --new-account."
64
+ );
65
+ }
66
+ if (pre.userIdStatus === "owner_mismatch") {
67
+ return (
68
+ "This connect code belongs to a different ClawChat account than the identity stored here. " +
69
+ "Ask the owner of THIS agent for a code, or activate as a brand-new agent with --new-account."
70
+ );
71
+ }
72
+ switch (pre.status) {
73
+ case "paired":
74
+ return (
75
+ "This connect code was already redeemed. If this agent lost its connection, ask your owner " +
76
+ `to send you the reconnect prompt from the ClawChat app and follow ${RECONNECT_GUIDE_URL}; ` +
77
+ "otherwise ask for a fresh code."
78
+ );
79
+ case "expired":
80
+ case "invalid":
81
+ return `This connect code is ${pre.status}. Ask your owner for a fresh code from the ClawChat app.`;
82
+ default:
83
+ return `This connect code is not pairable (status=${pre.status || "unknown"}). Ask your owner for a fresh code from the ClawChat app.`;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Refusal for a new-identity intent (`--new-account` or the interactive
89
+ * choice) on a bound code: without a user_id, `/connect` restores the bound
90
+ * agent instead of creating one. No flags, no minutes, no URL.
91
+ */
92
+ export function boundCodeNewIdentityMessage(): string {
93
+ return (
94
+ "This is a reconnect code bound to an existing agent, so it cannot create a new agent. " +
95
+ "Ask your owner for a normal connect code from the ClawChat app, or use this reconnect prompt " +
96
+ "on the agent it belongs to."
97
+ );
98
+ }
@@ -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
  );
package/src/runtime.ts CHANGED
@@ -19,6 +19,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
19
19
  import { buildActivationBootstrapText } from "./activation-greeting.ts";
20
20
  import { buildFriendGreetingEnvelope } from "./friend-greeting.ts";
21
21
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
22
+ import { readOnboardingReport } from "./onboarding-report.ts";
22
23
  import path from "node:path";
23
24
  import {
24
25
  ensureLivewareCli,
@@ -33,6 +34,7 @@ import {
33
34
  } from "./liveware-sample.ts";
34
35
  import { ClawlingApiError } from "./api-types.ts";
35
36
  import { RefreshManager } from "./refresh-manager.ts";
37
+ import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
36
38
  import {
37
39
  runOpenclawClawlingLogin,
38
40
  type LoginParams,
@@ -210,9 +212,13 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
210
212
  ]);
211
213
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
212
214
  // §C.1 — user-visible message emitted on permanent token expiry. Kept
213
- // byte-identical to the Hermes plugin (parity spec §C.1.4).
214
- const CLAWCHAT_TOKEN_EXPIRED_MESSAGE =
215
- "ClawChat token expired and could not be refreshed. Re-pair with `/clawchat-activate <code>`.";
215
+ // byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
216
+ // at the reconnect prompt (a code bound to this identity) rather than at a
217
+ // slash command: a fresh create code spent here would mint a second agent.
218
+ export const CLAWCHAT_TOKEN_EXPIRED_MESSAGE =
219
+ "ClawChat token expired and could not be refreshed. Ask your owner to send you the reconnect prompt from the ClawChat app, then follow " +
220
+ RECONNECT_GUIDE_URL +
221
+ ".";
216
222
  const CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR = "token expired — re-pair required";
217
223
 
218
224
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1067,6 +1073,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1067
1073
  pluginVersion,
1068
1074
  agentVersion,
1069
1075
  authenticated: false,
1076
+ onboarding: readOnboardingReport(),
1070
1077
  log,
1071
1078
  });
1072
1079
  // Ensure the liveware CLI is installed (async, never blocks startup).
@@ -1139,6 +1146,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1139
1146
  pluginVersion,
1140
1147
  agentVersion,
1141
1148
  authenticated: true,
1149
+ onboarding: readOnboardingReport(),
1142
1150
  log,
1143
1151
  });
1144
1152
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
71
71
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
72
72
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
73
73
  */
74
- export const DEFAULT_SKILLS_REF = "skills-v1.9.0";
74
+ export const DEFAULT_SKILLS_REF = "skills-v1.10.0";
75
75
 
76
76
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
77
77
  export const MAX_SKILL_BYTES = 256 * 1024;