@clawling/clawchat-plugin-openclaw 2026.10.21-1 → 2026.10.27-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.
Files changed (57) hide show
  1. package/README.md +124 -22
  2. package/dist/index.js +5 -2
  3. package/dist/src/api-client.js +46 -28
  4. package/dist/src/cli.js +74 -0
  5. package/dist/src/commands.js +96 -30
  6. package/dist/src/config.js +8 -4
  7. package/dist/src/framework-error-copy.js +1 -1
  8. package/dist/src/gate-outcome.js +4 -9
  9. package/dist/src/host-tools-guard.js +31 -7
  10. package/dist/src/orchestration.js +29 -0
  11. package/dist/src/outbound.js +315 -190
  12. package/dist/src/owner-sibling-notify.js +9 -4
  13. package/dist/src/reply-dispatcher.js +48 -16
  14. package/dist/src/runtime.js +92 -25
  15. package/dist/src/skill-update.js +9 -10
  16. package/dist/src/storage.js +48 -22
  17. package/dist/src/tool-capabilities.js +20 -1
  18. package/dist/src/tool-grants.js +9 -8
  19. package/dist/src/tools-schema.js +18 -9
  20. package/dist/src/tools.js +125 -36
  21. package/dist/src/turn-grants.js +61 -0
  22. package/dist/src/widget-guide.js +137 -0
  23. package/dist/src/widget-send.js +172 -0
  24. package/dist/src/widget-target.js +90 -0
  25. package/dist/src/ws-client.js +1 -1
  26. package/index.ts +9 -2
  27. package/openclaw.plugin.json +11 -2
  28. package/package.json +3 -3
  29. package/skills/clawchat-core/SKILL.md +144 -7
  30. package/skills/clawchat-liveware/SKILL.md +37 -25
  31. package/skills/clawchat-liveware-sample/SKILL.md +18 -7
  32. package/skills/manifest.json +18 -18
  33. package/src/api-client.ts +65 -43
  34. package/src/cli.ts +77 -0
  35. package/src/commands.ts +106 -26
  36. package/src/config.ts +10 -5
  37. package/src/framework-error-copy.ts +1 -1
  38. package/src/gate-outcome.ts +6 -1
  39. package/src/group-message-coalescer.ts +2 -2
  40. package/src/host-tools-guard.ts +33 -7
  41. package/src/orchestration.ts +35 -0
  42. package/src/outbound.ts +343 -215
  43. package/src/owner-sibling-notify.ts +9 -3
  44. package/src/protocol-types.ts +6 -0
  45. package/src/reply-dispatcher.ts +45 -17
  46. package/src/runtime.ts +93 -26
  47. package/src/skill-update.ts +9 -10
  48. package/src/storage.ts +75 -38
  49. package/src/tool-capabilities.ts +19 -1
  50. package/src/tool-grants.ts +10 -9
  51. package/src/tools-schema.ts +19 -9
  52. package/src/tools.ts +115 -23
  53. package/src/turn-grants.ts +58 -0
  54. package/src/widget-guide.ts +137 -0
  55. package/src/widget-send.ts +169 -0
  56. package/src/widget-target.ts +91 -0
  57. package/src/ws-client.ts +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over ClawChat Protoco
6
6
 
7
7
  - Plugin-owned WebSocket transport with auto-reconnect (exponential backoff + jitter), heartbeat, and ack tracking
8
8
  - Inbound `message.send` / `message.reply` with reply context
9
- - Outbound text replies as complete `message.send` / `message.reply` frames — the plugin consumes inbound streams but never emits streaming lifecycle frames
9
+ - Outbound text replies as complete `message.send` / `message.reply` frames by default; experimental `streamReplies: true` streams direct-chat replies (see [Reply Streaming](./docs/clawchat-plugin-openclaw.md#reply-streaming-experimental))
10
10
  - Typing indicators and filtered forwarding for thinking / tool-call content
11
11
  - Media fragments (image / file / audio / video) in either direction
12
12
  - Invite-code onboarding (no raw credentials) via `/clawchat-activate` or supported `openclaw channels add`, plus always-registered `clawchat_*` account/media tools
@@ -34,7 +34,7 @@ into OpenClaw:
34
34
  - **Runtime slash command (recommended).** Send `/clawchat-activate K7RM4TQP`
35
35
  in the chat where OpenClaw is running. Not a shell command — running
36
36
  `openclaw clawchat-activate` is expected to fail. This is the reliable
37
- first-time activation path on OpenClaw 2026.5.5 and newer.
37
+ first-time activation path on supported hosts (OpenClaw 2026.8.1 and newer).
38
38
  - **CLI `channels add`** when the host exposes `clawchat-plugin-openclaw` in its
39
39
  channel catalog:
40
40
 
@@ -57,10 +57,9 @@ entry, and ensures plugin + tool policy lists cover `clawchat-plugin-openclaw`,
57
57
  all in one config mutation. A Gateway with config reload/hot restart picks
58
58
  up the new runtime; otherwise `openclaw gateway restart` is required.
59
59
 
60
- See [`docs/clawchat-plugin-openclaw.md`](./docs/clawchat-plugin-openclaw.md) §"Onboarding
61
- (activation)" for the full contract: persisted fields, default
62
- `groupMode`/`groupCommandMode`, behavior with stale CLI catalogs, and the
63
- expected `openclaw.json` shape after activation succeeds.
60
+ See [activation](./docs/activation.md) for persisted fields, stale-catalog
61
+ recovery and bootstrap; [runtime registration](./docs/runtime-and-connection.md#correct-setupruntime-implementation-pattern)
62
+ covers the config mutation and host reload boundary.
64
63
 
65
64
  ## Standalone web chat (dev)
66
65
 
@@ -95,7 +94,7 @@ src/
95
94
  per-group idle/max batching for non-mention turns
96
95
  group-settings.ts per-group agent settings cache (GET /v1/agents/me/
97
96
  group-settings)
98
- reply-dispatcher.ts outbound reply routing (complete frames only)
97
+ reply-dispatcher.ts outbound reply routing (optional direct-chat streaming)
99
98
  no-reply.ts clawchat:no-reply / :silent suppression token guard
100
99
  login.runtime.ts invite-code exchange flow
101
100
  refresh-manager.ts ClawChat token refresh + auto-logout orchestration
@@ -130,9 +129,17 @@ tools/
130
129
  standalone-webchat.html
131
130
  docs/
132
131
  README.md typed index of the docs/ tree
133
- clawchat-plugin-openclaw.md full plugin reference (protocol use, config,
134
- SQLite, WS logs, troubleshooting, diagrams)
135
- client-integration.md ClawChat Protocol v2 wire contract (authoritative)
132
+ clawchat-plugin-openclaw.md overview, config, replies, media, troubleshooting
133
+ runtime-and-connection.md SQLite, installation recovery, host entrypoints, WS logs
134
+ skill-updates.md bundled skills, consent, updates and tombstones
135
+ liveware-sample.md sample auto-boot, relaunch and process lifecycle
136
+ tools.md registered tools and argument/server constraints
137
+ tool-grants.md non-owner access, approval and local-media boundaries
138
+ client-integration.md Protocol v2 plugin delta and upstream pointers
139
+ activation.md activation entry points, persistence, and recovery
140
+ token-refresh.md token refresh and auto-logout; per-plugin differences
141
+ http-endpoints.md HTTP request inventory mapped to client methods
142
+ cc-memory.md legacy redirect to clawchat-memory.md
136
143
  clawchat-memory.md file-backed memory contract (shared with Hermes)
137
144
  mention-message.md clawchat_mention_message tool reference
138
145
  per-group-mode.md per-chat_id groupMode / groupCommandMode overrides
@@ -147,22 +154,26 @@ docs/
147
154
  Start at [`docs/README.md`](./docs/README.md) — the typed index covering
148
155
  operators, in-chat agents, and plugin developers.
149
156
 
150
- The main reference is
151
- [`docs/clawchat-plugin-openclaw.md`](./docs/clawchat-plugin-openclaw.md), which covers:
152
-
153
- - Full configuration reference
154
- - Onboarding / activation details
155
- - REST endpoint table
156
- - Inbound streaming frame shapes (`message.created` / `message.add` / `message.done` / `message.reply`) — consumed, never produced
157
- - End-to-end sequence diagram
158
- - Media pipeline (inbound download / outbound upload)
159
- - Troubleshooting
157
+ [`docs/clawchat-plugin-openclaw.md`](./docs/clawchat-plugin-openclaw.md) is the
158
+ configuration, replies/media and troubleshooting overview. Detailed contracts:
159
+
160
+ - [Runtime and connection](./docs/runtime-and-connection.md): SQLite, installation
161
+ conflicts, reclaim, host registration, WebSocket delivery and log templates.
162
+ - [Skill updates](./docs/skill-updates.md): six bundled skills, consent, integrity
163
+ checks and managed-skill cleanup.
164
+ - [Liveware Sample](./docs/liveware-sample.md): bootstrap/relaunch, isolated CLI
165
+ homes, persistence, restart budgets and recovery.
166
+ - [Tools](./docs/tools.md) and [tool grants](./docs/tool-grants.md): registered
167
+ operations and non-owner access boundaries.
168
+ - [Activation](./docs/activation.md), [memory and prompts](./docs/clawchat-memory.md),
169
+ [HTTP inventory](./docs/http-endpoints.md) and [Protocol v2](./docs/client-integration.md)
170
+ cover their respective interfaces; the server implementation wins for wire formats.
160
171
 
161
172
  ## Development
162
173
 
163
174
  ```bash
164
175
  # Tests
165
- npx vitest run
176
+ npm test -- --run
166
177
 
167
178
  # Typecheck
168
179
  npm run typecheck
@@ -170,7 +181,7 @@ npm run typecheck
170
181
 
171
182
  Tests live next to the source they cover (`*.test.ts`). The development entrypoint stays in TypeScript for the OpenClaw extension loader, while npm installs use the compiled runtime entrypoint generated by `npm run build` / `prepack` under `dist/`.
172
183
 
173
- Functional E2E test cases are documented in the root ClawChat workspace E2E
184
+ Functional E2E test cases are documented in the aggregator repo's `e2e/`
174
185
  docs; keep that guide updated when adding or changing E2E flows.
175
186
 
176
187
  For OpenClaw host SDK/source lookup while developing this plugin, optionally
@@ -184,6 +195,97 @@ npm run dev:openclaw-source
184
195
  This checkout is local-only. It is ignored by git and is not required to run the
185
196
  plugin tests or publish the package.
186
197
 
198
+ ## Branches and releasing
199
+
200
+ Everything reaches `main` through a GitHub PR; nothing is pushed to `main`
201
+ directly.
202
+
203
+ - Day-to-day, non-urgent changes are merged into the `pre-release` branch, not
204
+ `main`, also through a GitHub PR (`--base pre-release`) that is
205
+ squash-merged. `main` only moves when a release is cut, so it always equals
206
+ the newest published version.
207
+ - An urgent fix (security, widespread connection failure) may target `main`
208
+ directly with a squash-merged PR that includes the version bump; afterwards
209
+ merge `main` back into `pre-release`.
210
+
211
+ A release runs in this fixed order — never publish first and merge later, and
212
+ never tag a commit that is not on `main`:
213
+
214
+ 1. **Bump the version on `pre-release`.** Set `package.json` `version` to the
215
+ new `YYYY.M.D-N` (no zero padding, e.g. `2026.10.7-1`; `-N` counts releases
216
+ on the same day) in a commit titled `chore(release): openclaw <old> -> <new>`,
217
+ and run `npm test -- --run` and `npm run typecheck`.
218
+ 2. **Merge `pre-release` into `main` through a PR, with a merge commit:**
219
+
220
+ ```bash
221
+ (
222
+ set -e
223
+ gh pr create --base main --head pre-release --title "release: <version>"
224
+ gh pr merge <n> --merge # merge commit, not squash
225
+ test "$(gh pr view <n> --json state --jq .state)" = MERGED
226
+ git fetch origin
227
+ git push origin origin/main:pre-release # fast-forward pre-release to main (not a force push)
228
+ )
229
+ ```
230
+
231
+ 3. **Tag the merge commit on `main` and push the tag.** The tag name equals
232
+ the `package.json` version, without a `v`; `<n>` is the release PR from
233
+ step 2, whose merge commit is tagged (not whatever `main` points to now):
234
+
235
+ Use the PR's merge commit because the floating `origin/main` may already
236
+ contain a later merge.
237
+
238
+ ```bash
239
+ (
240
+ set -e
241
+ git fetch origin --tags
242
+ release_commit=$(gh pr view <n> --json mergeCommit --jq .mergeCommit.oid)
243
+ git merge-base --is-ancestor "$release_commit" origin/main
244
+ git tag -a <version> "$release_commit" -m "<version>: <one-line summary>"
245
+ git push origin <version>
246
+ )
247
+ ```
248
+
249
+ 4. **Publish to npm from a clean checkout of the tag:**
250
+
251
+ ```bash
252
+ (
253
+ set -e
254
+ git worktree add --detach .worktrees/release-<version> <version>
255
+ cd .worktrees/release-<version>
256
+ test -z "$(git status --porcelain)" # must be clean
257
+ npm install # no lockfile is committed (package-lock.json is gitignored)
258
+ npm test -- --run
259
+ npm run release # npm publish --tag latest; guard, typecheck, then prepack builds dist/
260
+ )
261
+ ```
262
+
263
+ The subshell stops at the first failing command, so a failed install or
264
+ test never reaches `npm run release`.
265
+
266
+ Every `npm publish` runs the release guard through `prepublishOnly`, before
267
+ typecheck and before `prepack` changes the manifest. The guard requires a
268
+ local annotated tag named exactly after `package.json`'s version, HEAD at
269
+ that tag's commit, and a clean working tree (including non-ignored untracked
270
+ files). It checks that `origin` has the same tag object, fetches `origin/main`,
271
+ and requires the tag commit to be an ancestor of that refreshed branch.
272
+ Any Git error blocks publishing; there is no npm dist-tag exemption.
273
+ Run `npm publish --dry-run` from the tag checkout to exercise the guard.
274
+ Never skip the guard; fix the checkout instead.
275
+
276
+ 5. **Record the release.** Use the commit SHA tagged in step 3 as `<sha>`:
277
+
278
+ ```bash
279
+ gh release create <version> --verify-tag --title <version> --notes "commit: <sha>
280
+ npm: @clawling/clawchat-plugin-openclaw@<version>"
281
+ ```
282
+
283
+ Add later results with `gh release edit <version> --notes "<updated notes>"`,
284
+ keeping the commit and npm version in the notes; never move the tag.
285
+
286
+ Tag messages, PR titles, PR bodies and release notes are public like commit
287
+ messages.
288
+
187
289
  ## License
188
290
 
189
291
  MIT — see [LICENSE](./LICENSE).
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
2
  import { openclawClawlingPlugin } from "./src/channel.js";
3
- import { registerOpenclawClawlingCommands } from "./src/commands.js";
3
+ import { registerInstallationCli, registerInstallationGatewayMethods, registerOpenclawClawlingCommands, } from "./src/commands.js";
4
4
  import { openclawClawlingConfigSchema } from "./src/config.js";
5
5
  import { registerHostToolsGuard } from "./src/host-tools-guard.js";
6
6
  import { registerClawChatPromptInjection, } from "./src/prompt-injection.js";
@@ -29,7 +29,7 @@ const channelEntry = defineChannelPluginEntry({
29
29
  },
30
30
  });
31
31
  /**
32
- * Chat slash commands (`/clawchat-activate`, `/clawchat-output`) are registered
32
+ * Chat slash commands and installation gateway methods are registered
33
33
  * in EVERY registration mode except `cli-metadata`, not inside `registerFull`.
34
34
  *
35
35
  * The host loads the plugin more than once per process — a `full` load and,
@@ -59,8 +59,11 @@ export default {
59
59
  ...channelEntry,
60
60
  register: (api) => {
61
61
  channelEntry.register(api);
62
+ // Every mode, including cli-metadata: CLI discovery loads that mode.
63
+ registerInstallationCli(api);
62
64
  if (api.registrationMode !== "cli-metadata") {
63
65
  registerOpenclawClawlingCommands(api);
66
+ registerInstallationGatewayMethods(api);
64
67
  if (!FULL_REGISTRATION_MODES.has(String(api.registrationMode)))
65
68
  registerHostToolsGuard(api);
66
69
  }
@@ -1,3 +1,5 @@
1
+ import { orchestrationOriginHeaders } from "./orchestration.js";
2
+ export { orchestrationOriginHeaders } from "./orchestration.js";
1
3
  import { ClawlingApiError, } from "./api-types.js";
2
4
  import { CHANNEL_ID } from "./config.js";
3
5
  const ORCH = "/v1/agents/me/orchestration";
@@ -80,7 +82,8 @@ export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
80
82
  * Unauthenticated: the refresh token in the body IS the credential, so we send
81
83
  * NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
82
84
  * id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
83
- * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
85
+ * HTTP 200 by contract — branch on the envelope `code`; numeric `10003` also
86
+ * proves invalid refresh credentials on non-200 responses. This is
84
87
  * a standalone function (not a method on the token-bearing client) precisely
85
88
  * because no bearer token participates.
86
89
  *
@@ -137,8 +140,22 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
137
140
  };
138
141
  }
139
142
  if (res.status !== 200) {
140
- // Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
143
+ // Explicit invalid-refresh credentials are PERMANENT regardless of HTTP
144
+ // status, matching Hermes; retries must not hide the backend's rejection.
141
145
  const text = await res.text().catch(() => "");
146
+ try {
147
+ const parsed = JSON.parse(text);
148
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) &&
149
+ typeof parsed.code === "number" && parsed.code === CODE_INVALID_REFRESH) {
150
+ const message = (typeof parsed.msg === "string" && parsed.msg) ||
151
+ (typeof parsed.message === "string" && parsed.message) ||
152
+ `refresh http ${res.status} code=10003`;
153
+ return { kind: "permanent", code: CODE_INVALID_REFRESH, message };
154
+ }
155
+ }
156
+ catch {
157
+ // Unparseable bodies do not prove invalid credentials; keep them transient.
158
+ }
142
159
  return {
143
160
  kind: "transient",
144
161
  status: res.status,
@@ -316,8 +333,9 @@ export function createOpenclawClawlingApiClient(opts) {
316
333
  * build a raw client the same way. Proactive rotation is still picked up here for
317
334
  * free, since the token is read fresh from the account at call time.
318
335
  */
319
- async function orchCall(method, path, init) {
320
- const res = await doFetch(method, path, init);
336
+ async function orchCall(method, path, origin, init) {
337
+ const headers = orchestrationOriginHeaders(origin);
338
+ const res = await doFetch(method, path, { ...init, headers: { ...init?.headers, ...headers } });
321
339
  if (res.status === 401 || res.status === 403) {
322
340
  throw new ClawlingApiError("auth", `unauthorized (status ${res.status})`, {
323
341
  status: res.status,
@@ -453,34 +471,34 @@ export function createOpenclawClawlingApiClient(opts) {
453
471
  assertNonBlankId(friendUserId, "removeFriend: friendUserId");
454
472
  return await call("DELETE", `/v1/friendships/${encodeURIComponent(friendUserId)}`);
455
473
  },
456
- async orchListAgents() {
457
- return await orchCall("GET", `${ORCH}/agents`);
474
+ async orchListAgents(origin) {
475
+ return await orchCall("GET", `${ORCH}/agents`, origin);
458
476
  },
459
- async orchGetAgent(agentId) {
460
- return await orchCall("GET", `${ORCH}/agents/${encodeURIComponent(agentId)}`);
477
+ async orchGetAgent(agentId, origin) {
478
+ return await orchCall("GET", `${ORCH}/agents/${encodeURIComponent(agentId)}`, origin);
461
479
  },
462
- async orchSetAgentBehavior(agentId, behavior) {
463
- return await orchCall("PATCH", `${ORCH}/agents/${encodeURIComponent(agentId)}`, orchJson({ behavior }));
480
+ async orchSetAgentBehavior(agentId, behavior, origin) {
481
+ return await orchCall("PATCH", `${ORCH}/agents/${encodeURIComponent(agentId)}`, origin, orchJson({ behavior }));
464
482
  },
465
- async orchListGroups() {
466
- return await orchCall("GET", `${ORCH}/groups`);
483
+ async orchListGroups(origin) {
484
+ return await orchCall("GET", `${ORCH}/groups`, origin);
467
485
  },
468
- async orchGetGroup(cid) {
469
- return await orchCall("GET", `${ORCH}/groups/${encodeURIComponent(cid)}`);
486
+ async orchGetGroup(cid, origin) {
487
+ return await orchCall("GET", `${ORCH}/groups/${encodeURIComponent(cid)}`, origin);
470
488
  },
471
- async orchSetGroupPrompt(cid, description) {
472
- return await orchCall("PATCH", `${ORCH}/groups/${encodeURIComponent(cid)}`, orchJson({ description }));
489
+ async orchSetGroupPrompt(cid, description, origin) {
490
+ return await orchCall("PATCH", `${ORCH}/groups/${encodeURIComponent(cid)}`, origin, orchJson({ description }));
473
491
  },
474
- async orchCreateGroup(title, agentIds) {
475
- return await orchCall("POST", `${ORCH}/groups`, orchJson({ title, agent_ids: agentIds }));
492
+ async orchCreateGroup(title, agentIds, origin) {
493
+ return await orchCall("POST", `${ORCH}/groups`, origin, orchJson({ title, agent_ids: agentIds }));
476
494
  },
477
- async orchAddGroupMember(cid, agentId) {
478
- return await orchCall("POST", `${ORCH}/groups/${encodeURIComponent(cid)}/members`, orchJson({ agent_id: agentId }));
495
+ async orchAddGroupMember(cid, agentId, origin) {
496
+ return await orchCall("POST", `${ORCH}/groups/${encodeURIComponent(cid)}/members`, origin, orchJson({ agent_id: agentId }));
479
497
  },
480
- async orchRemoveGroupMember(cid, agentId) {
481
- return await orchCall("DELETE", `${ORCH}/groups/${encodeURIComponent(cid)}/members/${encodeURIComponent(agentId)}`);
498
+ async orchRemoveGroupMember(cid, agentId, origin) {
499
+ return await orchCall("DELETE", `${ORCH}/groups/${encodeURIComponent(cid)}/members/${encodeURIComponent(agentId)}`, origin);
482
500
  },
483
- async orchSetGroupAgentSettings(cid, agentId, patch) {
501
+ async orchSetGroupAgentSettings(cid, agentId, patch, origin) {
484
502
  // `!== undefined` rather than truthiness: `muted: false` is a real value
485
503
  // the backend must see; an absent key means "leave unchanged".
486
504
  const body = {};
@@ -490,13 +508,13 @@ export function createOpenclawClawlingApiClient(opts) {
490
508
  body.reply_mode = patch.replyMode;
491
509
  if (patch.batchDelaySeconds !== undefined)
492
510
  body.batch_delay_seconds = patch.batchDelaySeconds;
493
- return await orchCall("PATCH", `${ORCH}/groups/${encodeURIComponent(cid)}/agents/${encodeURIComponent(agentId)}`, orchJson(body));
511
+ return await orchCall("PATCH", `${ORCH}/groups/${encodeURIComponent(cid)}/agents/${encodeURIComponent(agentId)}`, origin, orchJson(body));
494
512
  },
495
- async orchCreateConnectCode() {
496
- return await orchCall("POST", `${ORCH}/connect-codes`);
513
+ async orchCreateConnectCode(origin) {
514
+ return await orchCall("POST", `${ORCH}/connect-codes`, origin);
497
515
  },
498
- async orchGetConnectCode(code) {
499
- return await orchCall("GET", `${ORCH}/connect-codes/${encodeURIComponent(code)}`);
516
+ async orchGetConnectCode(code, origin) {
517
+ return await orchCall("GET", `${ORCH}/connect-codes/${encodeURIComponent(code)}`, origin);
500
518
  },
501
519
  async registerApp(params) {
502
520
  assertNonBlankId(params.appId, "registerApp: appId");
@@ -0,0 +1,74 @@
1
+ import * as gatewayRuntime from "openclaw/plugin-sdk/gateway-runtime";
2
+ /** Older SDKs have no public URL redactor. In that case omit error text entirely. */
3
+ function safeRpcError(error, opts) {
4
+ const name = error instanceof Error && /^(?:Error|[A-Za-z][A-Za-z0-9]*Error)$/u.test(error.name)
5
+ ? error.name : "Error";
6
+ const redact = "redactSensitiveUrlLikeString" in gatewayRuntime ? gatewayRuntime.redactSensitiveUrlLikeString : undefined;
7
+ if (typeof redact === "function" && error instanceof Error) {
8
+ try {
9
+ let message = error.message;
10
+ // Never echo explicitly supplied gateway credentials, even outside URLs.
11
+ for (const secret of [opts.token, opts.password]) {
12
+ if (secret)
13
+ message = message.replaceAll(secret, "[redacted]");
14
+ }
15
+ return redact(message).replace(/[\r\n]+/gu, " ").slice(0, 240);
16
+ }
17
+ catch {
18
+ return name;
19
+ }
20
+ }
21
+ return name;
22
+ }
23
+ export function registerClawChatCli(program, api) {
24
+ const root = program.command("clawchat").description("ClawChat installation conflict tools");
25
+ for (const action of ["reclaim", "status"]) {
26
+ const sub = root.command(action)
27
+ .description(action === "reclaim" ? "Reclaim this ClawChat installation" : "Show ClawChat installation status")
28
+ .option("--account <id>", "ClawChat account id (default: default)")
29
+ .option("--offline", "Operate on local storage; use only while the gateway is stopped (cannot clear a running gateway's memory stop)")
30
+ .option("--json", "Print the result as JSON");
31
+ gatewayRuntime.addGatewayClientOptions(sub);
32
+ sub.action(async (opts) => {
33
+ await runCliAction(action, opts, api);
34
+ });
35
+ }
36
+ }
37
+ async function runCliAction(action, opts, api) {
38
+ // The registrar builds only Commander metadata; storage/runtime work starts
39
+ // after a leaf command is invoked, and only offline calls open local storage.
40
+ const { resolveInstallationAccountId, runInstallationCommand } = await import("./commands.js");
41
+ let accountId;
42
+ try {
43
+ accountId = resolveInstallationAccountId(opts.account);
44
+ }
45
+ catch {
46
+ console.error("--account requires a valid non-empty account id");
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ let result;
51
+ try {
52
+ if (opts.offline) {
53
+ result = await runInstallationCommand(action, accountId, { stateDir: api.runtime.state?.resolveStateDir?.() });
54
+ }
55
+ else {
56
+ const response = await gatewayRuntime.callGatewayFromCli(`clawchat.${action}`, opts, { accountId });
57
+ if (typeof response.ok !== "boolean" || typeof response.text !== "string") {
58
+ throw new Error("Invalid gateway response");
59
+ }
60
+ result = { ok: response.ok, text: response.text };
61
+ }
62
+ }
63
+ catch (error) {
64
+ console.error(`ClawChat ${action} failed: ${safeRpcError(error, opts)}`);
65
+ if (!opts.offline) {
66
+ console.error(`If the OpenClaw gateway is not running, use \`openclaw clawchat ${action} --offline\` to operate on local storage directly.`);
67
+ }
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ console.log(opts.json ? JSON.stringify(result) : result.text);
72
+ if (!result.ok)
73
+ process.exitCode = 1;
74
+ }
@@ -73,6 +73,100 @@ function extractActivationIntent(value) {
73
73
  function errorMessage(err) {
74
74
  return err instanceof Error ? err.message : String(err);
75
75
  }
76
+ const INSTALLATION_COMMAND_FAILED = "ClawChat installation conflict storage unavailable; command failed.";
77
+ /**
78
+ * Reject malformed explicit IDs rather than accidentally targeting default.
79
+ * Validity follows the host's account-id normalizer: an ID is accepted only if
80
+ * it is already canonical (up to case), so IDs the host would rewrite or map
81
+ * to `default` are rejected.
82
+ */
83
+ export function resolveInstallationAccountId(value) {
84
+ if (value === undefined)
85
+ return normalizeOpenclawClawlingAccountId(undefined);
86
+ const trimmed = typeof value === "string" ? value.trim() : "";
87
+ const normalized = normalizeOpenclawClawlingAccountId(trimmed);
88
+ if (!trimmed || normalized !== trimmed.toLowerCase()) {
89
+ throw new Error("accountId must be a valid non-empty account id");
90
+ }
91
+ return normalized;
92
+ }
93
+ /** One implementation for slash commands, gateway RPC and offline CLI. */
94
+ export async function runInstallationCommand(action, accountId, deps = {}) {
95
+ try {
96
+ const store = getClawChatStore(deps.stateDir ? { dbPath: clawChatDbPathForStateDir(deps.stateDir) } : {});
97
+ const lookup = store.lookupSuperseded(accountId);
98
+ if (lookup.status === "unavailable")
99
+ return { ok: false, text: "ClawChat installation conflict storage unavailable; try again after storage recovers (a gateway restart also drops a stop that could not be saved)." };
100
+ if (action === "reclaim") {
101
+ if (lookup.status === "absent" && !hasReclaimWaiter(accountId) && !recalledSuperseded(accountId))
102
+ return { ok: true, text: "This ClawChat installation is not superseded; nothing to reclaim." };
103
+ if (!store.clearSuperseded(accountId))
104
+ return { ok: false, text: "Unable to clear ClawChat installation conflict; reclaim failed." };
105
+ rememberSuperseded(accountId, null);
106
+ return { ok: true, text: signalReclaim(accountId)
107
+ ? "ClawChat reclaim requested; reconnecting this installation."
108
+ : "ClawChat conflict cleared; restart the OpenClaw gateway to reconnect this installation." };
109
+ }
110
+ const fields = installationStatus(lookup.status === "row" ? lookup.row : recalledSuperseded(accountId), resolveInstallId(store));
111
+ const connection = fields.superseded ? "superseded" : deps.connectionState
112
+ ? await deps.connectionState(accountId) ?? "disconnected"
113
+ : "unknown (gateway not reachable; offline snapshot)";
114
+ return { ok: true, text: [
115
+ `ClawChat account: ${accountId}`, `install_id: ${fields.installId ?? "unavailable"}`,
116
+ `connection: ${connection}`, `superseded: ${fields.superseded}`,
117
+ `superseded_at: ${fields.supersededAt ? new Date(fields.supersededAt).toISOString() : "-"}`,
118
+ `by_client_version: ${fields.byClientVersion ?? "unknown"}`,
119
+ `owner_notified: ${Boolean(fields.ownerNotifiedAt)}`,
120
+ ].join("\n") };
121
+ }
122
+ catch {
123
+ return { ok: false, text: INSTALLATION_COMMAND_FAILED };
124
+ }
125
+ }
126
+ async function runLiveInstallationCommand(api, action, accountId) {
127
+ try {
128
+ return await runInstallationCommand(action, accountId, {
129
+ stateDir: api.runtime.state?.resolveStateDir?.(),
130
+ connectionState: async (id) => {
131
+ const { getOpenclawClawlingClient } = await import("./runtime.js");
132
+ return getOpenclawClawlingClient(id)?.state;
133
+ },
134
+ });
135
+ }
136
+ catch {
137
+ return { ok: false, text: INSTALLATION_COMMAND_FAILED };
138
+ }
139
+ }
140
+ export function registerInstallationGatewayMethods(api) {
141
+ for (const action of ["reclaim", "status"]) {
142
+ api.registerGatewayMethod(`clawchat.${action}`, async ({ params, respond }) => {
143
+ let accountId;
144
+ try {
145
+ accountId = resolveInstallationAccountId(params.accountId);
146
+ }
147
+ catch {
148
+ respond(false, undefined, { code: "INVALID_REQUEST", message: "accountId must be a valid non-empty account id" });
149
+ return;
150
+ }
151
+ respond(true, await runLiveInstallationCommand(api, action, accountId));
152
+ }, { scope: action === "reclaim" ? "operator.admin" : "operator.read" });
153
+ }
154
+ }
155
+ /**
156
+ * Register the `openclaw clawchat reclaim|status` CLI root. The implementation
157
+ * is imported lazily inside the registrar so registration never opens storage
158
+ * or starts a client. Keep this dynamic import in src/: tsc leaves the `.ts`
159
+ * extension of a dynamic import in the root index.ts unrewritten in dist.
160
+ */
161
+ export function registerInstallationCli(api) {
162
+ api.registerCli(async ({ program }) => {
163
+ const { registerClawChatCli } = await import("./cli.js");
164
+ registerClawChatCli(program, api);
165
+ }, {
166
+ commands: ["clawchat"],
167
+ descriptors: [{ name: "clawchat", description: "ClawChat installation conflict tools", hasSubcommands: true }],
168
+ });
169
+ }
76
170
  function readRecord(value) {
77
171
  return value && typeof value === "object" && !Array.isArray(value)
78
172
  ? value
@@ -212,36 +306,8 @@ export function registerOpenclawClawlingCommands(api) {
212
306
  if (flag.error)
213
307
  return { text: `${flag.error}. Usage: /clawchat-${action} [--account <id>]` };
214
308
  const accountId = flag.accountId ?? normalizeOpenclawClawlingAccountId(ctx.accountId);
215
- try {
216
- const stateDir = api.runtime.state?.resolveStateDir?.();
217
- const store = getClawChatStore(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {});
218
- const lookup = store.lookupSuperseded(accountId);
219
- if (lookup.status === "unavailable")
220
- return { text: "ClawChat installation conflict storage unavailable; try again after storage recovers (a gateway restart also drops a stop that could not be saved)." };
221
- if (action === "reclaim") {
222
- if (lookup.status === "absent" && !hasReclaimWaiter(accountId) && !recalledSuperseded(accountId))
223
- return { text: "This ClawChat installation is not superseded; nothing to reclaim." };
224
- if (!store.clearSuperseded(accountId))
225
- return { text: "Unable to clear ClawChat installation conflict; reclaim failed." };
226
- rememberSuperseded(accountId, null);
227
- return { text: signalReclaim(accountId)
228
- ? "ClawChat reclaim requested; reconnecting this installation."
229
- : "ClawChat conflict cleared; restart the OpenClaw gateway to reconnect this installation." };
230
- }
231
- const fields = installationStatus(lookup.status === "row" ? lookup.row : recalledSuperseded(accountId), resolveInstallId(store));
232
- const { getOpenclawClawlingClient } = await import("./runtime.js");
233
- const connection = fields.superseded ? "superseded" : getOpenclawClawlingClient(accountId)?.state ?? "disconnected";
234
- return { text: [
235
- `ClawChat account: ${accountId}`, `install_id: ${fields.installId ?? "unavailable"}`,
236
- `connection: ${connection}`, `superseded: ${fields.superseded}`,
237
- `superseded_at: ${fields.supersededAt ? new Date(fields.supersededAt).toISOString() : "-"}`,
238
- `by_client_version: ${fields.byClientVersion ?? "unknown"}`,
239
- `owner_notified: ${Boolean(fields.ownerNotifiedAt)}`,
240
- ].join("\n") };
241
- }
242
- catch {
243
- return { text: "ClawChat installation conflict storage unavailable; command failed." };
244
- }
309
+ const result = await runLiveInstallationCommand(api, action, accountId);
310
+ return { text: result.text };
245
311
  },
246
312
  });
247
313
  }
@@ -35,10 +35,10 @@ export const DEFAULT_HEARTBEAT = {
35
35
  timeout: 10_000,
36
36
  };
37
37
  export const DEFAULT_ACK = {
38
- // 15s tolerates a slow server + one retry without false timeouts.
38
+ // Allow a slow server 15s before each same-frame timeout retry.
39
39
  timeout: 15_000,
40
- // Keep false: auto-resend on timeout risks duplicate messages; the
41
- // reconnect path re-queues via `queueWhileReconnecting` instead.
40
+ timeoutRetries: 2,
41
+ // Core ws-client policy; aligned sends use timeoutRetries.
42
42
  autoResendOnTimeout: false,
43
43
  };
44
44
  export const openclawClawlingAccountConfigSchema = {
@@ -116,6 +116,7 @@ export const openclawClawlingAccountConfigSchema = {
116
116
  additionalProperties: false,
117
117
  properties: {
118
118
  timeout: { type: "integer", minimum: 100 },
119
+ timeoutRetries: { type: "integer", minimum: 0, maximum: 5 },
119
120
  autoResendOnTimeout: { type: "boolean" },
120
121
  },
121
122
  },
@@ -396,7 +397,7 @@ export function effectiveOutputVisibility(account, chatId, chatType) {
396
397
  * `SESSION_INT_KEYS`). An out-of-range value is clamped to the nearest bound,
397
398
  * a non-number falls back to the factory value. Every key here has a reader;
398
399
  * the shared keys this plugin has no use for are listed in
399
- * docs/clawchat-plugin-openclaw.md with the reason.
400
+ * docs/clawchat-plugin-openclaw.md#session-and-note-keys with the reason.
400
401
  * key -> [factory, minimum, maximum]
401
402
  */
402
403
  export const SESSION_INT_KEYS = {
@@ -439,6 +440,9 @@ function readAck(raw) {
439
440
  const s = raw && typeof raw === "object" ? raw : {};
440
441
  return {
441
442
  timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_ACK.timeout,
443
+ timeoutRetries: typeof s.timeoutRetries === "number" && Number.isFinite(s.timeoutRetries)
444
+ ? Math.max(0, Math.min(5, Math.trunc(s.timeoutRetries)))
445
+ : DEFAULT_ACK.timeoutRetries,
442
446
  autoResendOnTimeout: typeof s.autoResendOnTimeout === "boolean"
443
447
  ? s.autoResendOnTimeout
444
448
  : DEFAULT_ACK.autoResendOnTimeout,
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The host hands the channel its own English failure text (rate limit, expired
6
6
  * provider login, context overflow, billing, timeouts — see
7
- * `docs/clawchat-plugin-openclaw.md` "Framework error replies"). Posting that
7
+ * `docs/clawchat-plugin-openclaw.md#framework-error-replies`). Posting that
8
8
  * raw leaves a non-English owner with a message they cannot act on. Instead we
9
9
  * lead with one sentence in the owner's language saying what happened and what
10
10
  * to do, then keep the original text at the end so it can still be reported.
@@ -1,13 +1,8 @@
1
- /**
2
- * Maps backend owner-approval gate codes to structured tool outcomes.
3
- *
4
- * Backend gate codes (confirmed live contract):
5
- * 21001 — pending owner approval (payload: { request_id, operation, expires_at })
6
- * 21003 — policy forbidden (payload: { operation })
7
- *
8
- * Returns null for any other code so callers can fall through to normal handling.
9
- */
1
+ import { ownerOnlyMessage } from "./orchestration.js";
10
2
  export function mapGateOutcome(code, data) {
3
+ if (code === 21007)
4
+ return { status: "owner_turn_only",
5
+ operation: typeof data.operation === "string" ? data.operation : "", instruction: ownerOnlyMessage(false) };
11
6
  if (code === 21001) {
12
7
  return {
13
8
  status: "pending_owner_approval",