@openbrt/weclawbotctl 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,11 @@ it does not lay out text, choose fonts, or split pages for agents.
60
60
  weclawbotctl screen /path/to/screen-document.json
61
61
  ```
62
62
 
63
+ `screen` waits for the device status topic by default. It exits successfully
64
+ only after the firmware reports `applied`; a firmware `rejected` status or a
65
+ timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
66
+ publish acknowledgement is enough.
67
+
63
68
  The package also includes an OpenClaw integration: the `weclawbot-curator`
64
69
  skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
65
70
  `weclawbot_publish_screen_document`, `weclawbot_validate_activity`,
@@ -110,6 +115,16 @@ certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
110
115
  or use a certificate trusted by Node. The package does not rewrite other
111
116
  users' OpenClaw gateway certificates automatically.
112
117
 
118
+ If OpenClaw is installed outside `PATH`, pass it explicitly:
119
+
120
+ ```bash
121
+ weclawbotctl openclaw doctor --bin /path/to/openclaw
122
+ ```
123
+
124
+ When `weclawbotctl` is installed globally, it also checks the same npm global
125
+ `bin` directory for `openclaw`, which helps non-interactive SSH and systemd
126
+ environments where shell startup files are not loaded.
127
+
113
128
  To install the OpenClaw plugin from a local checkout during development:
114
129
 
115
130
  ```bash
@@ -244,6 +259,11 @@ the firmware supports forced replacement, use:
244
259
  weclawbotctl screen /path/to/screen-document.json --force
245
260
  ```
246
261
 
262
+ The command waits for the device's `applied`/`rejected` status by default, so
263
+ an Agent must not tell the user the content is on the screen until the command
264
+ returns success. If the device reports `stale_screen_revision`, regenerate the
265
+ document with the current revision or intentionally overwrite with `--force`.
266
+
247
267
  These commands use MQTT/TLS directly, publish QoS 1 without retain, and
248
268
  never create an offline command queue. See
249
269
  `docs/agent-direct-control-protocol.md` in the firmware repository for the
@@ -9,7 +9,7 @@ import process from "node:process";
9
9
 
10
10
  import { validateActivity } from "../lib/activity.mjs";
11
11
  import { validateScreenDocument } from "../lib/direct-control.mjs";
12
- import { normalizeCredentials, publishControl, testConnection } from "../lib/mqtt-control.mjs";
12
+ import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "../lib/mqtt-control.mjs";
13
13
 
14
14
  const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
15
15
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
@@ -202,10 +202,15 @@ async function commandUnbind(values) {
202
202
  }
203
203
 
204
204
  async function commandScreen(values) {
205
- const options = parseOptions(values, { credentials: credentialsPath(), force: false });
205
+ const options = parseOptions(values, {
206
+ credentials: credentialsPath(),
207
+ force: false,
208
+ wait: true,
209
+ timeout: 12,
210
+ });
206
211
  const file = String(options._[0] || "").trim();
207
212
  if (!file || options._.length !== 1) {
208
- throw new Error("Usage: weclawbotctl screen <document.json> [--force]");
213
+ throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]");
209
214
  }
210
215
  const document = JSON.parse(await fs.readFile(file, "utf8"));
211
216
  if (options.force) {
@@ -218,13 +223,41 @@ async function commandScreen(values) {
218
223
  if (!validation.ok) {
219
224
  throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
220
225
  }
221
- await publishControl(await requireCredentials(expandPath(options.credentials)), {
226
+ const control = {
222
227
  schema: "weclawbot.control.v1",
223
228
  id: `screen_${crypto.randomUUID()}`,
224
229
  kind: "screen_document",
225
230
  document,
226
- });
227
- console.log(JSON.stringify({ ok: true, id: document.id, pages: document.pages.length, force_replace: document.force_replace === true }));
231
+ };
232
+ const credentials = await requireCredentials(expandPath(options.credentials));
233
+ if (options.wait) {
234
+ const delivery = await publishControlAndWaitStatus(credentials, control, {
235
+ expectedDetail: document.id,
236
+ timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
237
+ });
238
+ if (delivery.status.kind !== "applied") {
239
+ throw new Error(`Device rejected screen document: ${delivery.status.detail || "unknown"}`);
240
+ }
241
+ console.log(JSON.stringify({
242
+ ok: true,
243
+ published: true,
244
+ applied: true,
245
+ id: document.id,
246
+ pages: document.pages.length,
247
+ force_replace: document.force_replace === true,
248
+ status: delivery.status,
249
+ }));
250
+ return;
251
+ }
252
+ await publishControl(credentials, control);
253
+ console.log(JSON.stringify({
254
+ ok: true,
255
+ published: true,
256
+ applied: null,
257
+ id: document.id,
258
+ pages: document.pages.length,
259
+ force_replace: document.force_replace === true,
260
+ }));
228
261
  }
229
262
 
230
263
  async function commandActivity(state, values) {
@@ -269,12 +302,12 @@ async function commandOpenClaw(values) {
269
302
 
270
303
  async function commandOpenClawInstall(values) {
271
304
  const options = parseOptions(values, {
272
- bin: process.env.OPENCLAW_BIN || "openclaw",
305
+ bin: process.env.OPENCLAW_BIN || "",
273
306
  spec: DEFAULT_OPENCLAW_PLUGIN_SPEC,
274
307
  force: true,
275
308
  doctor: true,
276
309
  });
277
- const openclaw = String(options.bin || "openclaw");
310
+ const openclaw = await resolveOpenClawBin(options.bin);
278
311
  const spec = String(options.spec || DEFAULT_OPENCLAW_PLUGIN_SPEC);
279
312
  const version = await runCaptured(openclaw, ["--version"], { timeoutMs: 10_000 });
280
313
  const versionCheck = openClawVersionCheck(version.stdout);
@@ -292,12 +325,12 @@ async function commandOpenClawInstall(values) {
292
325
 
293
326
  async function commandOpenClawDoctor(values) {
294
327
  const options = parseOptions(values, {
295
- bin: process.env.OPENCLAW_BIN || "openclaw",
328
+ bin: process.env.OPENCLAW_BIN || "",
296
329
  json: false,
297
330
  gateway: true,
298
331
  timeout: 20,
299
332
  });
300
- const openclaw = String(options.bin || "openclaw");
333
+ const openclaw = await resolveOpenClawBin(options.bin);
301
334
  const timeoutMs = Math.max(1, Number(options.timeout) || 20) * 1000;
302
335
  const checks = [];
303
336
 
@@ -350,6 +383,13 @@ function parseOptions(values, defaults = {}) {
350
383
  const [rawKey, inlineValue] = value.slice(2).split("=", 2);
351
384
  const key = rawKey.trim();
352
385
  if (!key) continue;
386
+ if (key.startsWith("no-")) {
387
+ const positive = key.slice(3);
388
+ if (typeof options[positive] === "boolean" && inlineValue === undefined) {
389
+ options[positive] = false;
390
+ continue;
391
+ }
392
+ }
353
393
  if (typeof options[key] === "boolean") {
354
394
  options[key] = inlineValue === undefined ? true : !/^(0|false|no|off)$/iu.test(inlineValue);
355
395
  } else {
@@ -583,6 +623,22 @@ async function fileExists(file) {
583
623
  }
584
624
  }
585
625
 
626
+ async function resolveOpenClawBin(value) {
627
+ if (value) return String(value);
628
+ const scriptDir = path.dirname(process.argv[1] || "");
629
+ const candidates = [
630
+ scriptDir ? path.join(scriptDir, "openclaw") : "",
631
+ path.join(os.homedir(), ".npm-global", "bin", "openclaw"),
632
+ path.join(os.homedir(), ".local", "bin", "openclaw"),
633
+ "/usr/local/bin/openclaw",
634
+ "/opt/homebrew/bin/openclaw",
635
+ ].filter(Boolean);
636
+ for (const candidate of candidates) {
637
+ if (await fileExists(candidate)) return candidate;
638
+ }
639
+ return "openclaw";
640
+ }
641
+
586
642
  function compactText(value) {
587
643
  return String(value || "").replace(/\s+/gu, " ").trim().slice(0, 500);
588
644
  }
@@ -604,7 +660,7 @@ function usage() {
604
660
  weclawbotctl unbind --yes
605
661
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
606
662
  weclawbotctl idle [--id correlation-id]
607
- weclawbotctl screen <document.json> [--force]
608
- weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--force=false]
609
- weclawbotctl openclaw doctor [--gateway=false] [--json]`);
663
+ weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
664
+ weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
665
+ weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
610
666
  }
package/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
8
8
 
9
9
  import { validateActivity } from "./lib/activity.mjs";
10
10
  import { validateScreenDocument } from "./lib/direct-control.mjs";
11
- import { normalizeCredentials, publishControl, testConnection } from "./lib/mqtt-control.mjs";
11
+ import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
12
12
 
13
13
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
14
14
 
@@ -68,8 +68,10 @@ export default defineToolPlugin({
68
68
  device_context: Type.Optional(Type.Any()),
69
69
  credentials_path: Type.Optional(Type.String()),
70
70
  force_replace: Type.Optional(Type.Boolean()),
71
+ wait_status: Type.Optional(Type.Boolean()),
72
+ timeout_seconds: Type.Optional(Type.Number()),
71
73
  }, { additionalProperties: false }),
72
- execute: async ({ document, device_context, credentials_path, force_replace }) => {
74
+ execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds }) => {
73
75
  const outbound = cloneObject(document);
74
76
  if (force_replace) {
75
77
  outbound.force_replace = true;
@@ -81,15 +83,34 @@ export default defineToolPlugin({
81
83
  if (!validation.ok) {
82
84
  return { ok: false, published: false, errors: validation.errors, validation };
83
85
  }
84
- await publishControl(await requireCredentials(credentials_path), {
86
+ const control = {
85
87
  schema: "weclawbot.control.v1",
86
88
  id: `screen_${crypto.randomUUID()}`,
87
89
  kind: "screen_document",
88
90
  document: outbound,
89
- });
91
+ };
92
+ const credentials = await requireCredentials(credentials_path);
93
+ if (wait_status !== false) {
94
+ const delivery = await publishControlAndWaitStatus(credentials, control, {
95
+ expectedDetail: outbound.id,
96
+ timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
97
+ });
98
+ return {
99
+ ok: delivery.status.kind === "applied",
100
+ published: true,
101
+ applied: delivery.status.kind === "applied",
102
+ rejected: delivery.status.kind === "rejected",
103
+ id: outbound.id,
104
+ pages: outbound.pages.length,
105
+ force_replace: outbound.force_replace === true,
106
+ status: delivery.status,
107
+ };
108
+ }
109
+ await publishControl(credentials, control);
90
110
  return {
91
111
  ok: true,
92
112
  published: true,
113
+ applied: null,
93
114
  id: outbound.id,
94
115
  pages: outbound.pages.length,
95
116
  force_replace: outbound.force_replace === true,
@@ -2,27 +2,33 @@ import mqtt from "mqtt";
2
2
 
3
3
  export async function publishControl(credentials, control) {
4
4
  const config = normalizeCredentials(credentials);
5
- if (!control || typeof control !== "object" || control.schema !== "weclawbot.control.v1") {
6
- throw new Error("invalid_control_message");
5
+ validateControl(control);
6
+ const client = connectMqtt(config);
7
+ try {
8
+ await onceConnected(client);
9
+ await publishJson(client, config.controlTopic, control);
10
+ } finally {
11
+ client.end(true);
7
12
  }
8
- const client = mqtt.connect(config.url, {
9
- clientId: config.clientId,
10
- username: config.username,
11
- password: config.password,
12
- clean: true,
13
- reconnectPeriod: 0,
14
- connectTimeout: 12_000,
15
- protocolVersion: 5,
16
- properties: { sessionExpiryInterval: 0 },
17
- });
13
+ }
14
+
15
+ export async function publishControlAndWaitStatus(credentials, control, options = {}) {
16
+ const config = normalizeCredentials(credentials);
17
+ validateControl(control);
18
+ if (!config.statusTopic) throw new Error("agent_status_topic_missing");
19
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs || 12_000));
20
+ const expectedDetail = string(options.expectedDetail);
21
+ const client = connectMqtt(config);
18
22
  try {
19
23
  await onceConnected(client);
20
- await new Promise((resolve, reject) => {
21
- client.publish(config.controlTopic, JSON.stringify(control), { qos: 1, retain: false }, (error) => {
22
- if (error) reject(error);
23
- else resolve();
24
- });
24
+ await subscribe(client, config.statusTopic);
25
+ const statusPromise = waitForStatus(client, {
26
+ timeoutMs,
27
+ expectedDetail,
25
28
  });
29
+ await publishJson(client, config.controlTopic, control);
30
+ const status = await statusPromise;
31
+ return { ok: status.kind === "applied", status };
26
32
  } finally {
27
33
  client.end(true);
28
34
  }
@@ -30,16 +36,7 @@ export async function publishControl(credentials, control) {
30
36
 
31
37
  export async function testConnection(credentials) {
32
38
  const config = normalizeCredentials(credentials);
33
- const client = mqtt.connect(config.url, {
34
- clientId: config.clientId,
35
- username: config.username,
36
- password: config.password,
37
- clean: true,
38
- reconnectPeriod: 0,
39
- connectTimeout: 12_000,
40
- protocolVersion: 5,
41
- properties: { sessionExpiryInterval: 0 },
42
- });
39
+ const client = connectMqtt(config);
43
40
  try {
44
41
  await onceConnected(client);
45
42
  } finally {
@@ -62,11 +59,91 @@ export function normalizeCredentials(value) {
62
59
  const password = string(mqttConfig?.password);
63
60
  const clientId = string(mqttConfig?.client_id);
64
61
  const controlTopic = string(topics?.control);
62
+ const statusTopic = string(topics?.status);
65
63
  if (!url || !username || !password || !clientId || !controlTopic) {
66
64
  throw new Error("agent_credentials_incomplete");
67
65
  }
68
66
  if (!/^wss:\/\//u.test(url)) throw new Error("agent_mqtt_requires_wss");
69
- return { url, username, password, clientId, controlTopic };
67
+ return { url, username, password, clientId, controlTopic, statusTopic };
68
+ }
69
+
70
+ function connectMqtt(config) {
71
+ return mqtt.connect(config.url, {
72
+ clientId: config.clientId,
73
+ username: config.username,
74
+ password: config.password,
75
+ clean: true,
76
+ reconnectPeriod: 0,
77
+ connectTimeout: 12_000,
78
+ protocolVersion: 5,
79
+ properties: { sessionExpiryInterval: 0 },
80
+ });
81
+ }
82
+
83
+ function validateControl(control) {
84
+ if (!control || typeof control !== "object" || control.schema !== "weclawbot.control.v1") {
85
+ throw new Error("invalid_control_message");
86
+ }
87
+ }
88
+
89
+ function publishJson(client, topic, value) {
90
+ return new Promise((resolve, reject) => {
91
+ client.publish(topic, JSON.stringify(value), { qos: 1, retain: false }, (error) => {
92
+ if (error) reject(error);
93
+ else resolve();
94
+ });
95
+ });
96
+ }
97
+
98
+ function subscribe(client, topic) {
99
+ return new Promise((resolve, reject) => {
100
+ client.subscribe(topic, { qos: 1 }, (error) => {
101
+ if (error) reject(error);
102
+ else resolve();
103
+ });
104
+ });
105
+ }
106
+
107
+ function waitForStatus(client, { timeoutMs, expectedDetail }) {
108
+ return new Promise((resolve, reject) => {
109
+ const timeout = setTimeout(() => finish(new Error("device_status_timeout")), timeoutMs);
110
+ const finish = (error, status) => {
111
+ clearTimeout(timeout);
112
+ client.removeListener("message", onMessage);
113
+ client.removeListener("error", onError);
114
+ if (error) reject(error);
115
+ else resolve(status);
116
+ };
117
+ const onError = (error) => finish(error);
118
+ const onMessage = (_topic, payload) => {
119
+ const status = parseStatus(payload);
120
+ if (!status) return;
121
+ if (status.kind === "applied") {
122
+ if (expectedDetail && status.detail !== expectedDetail) return;
123
+ finish(null, status);
124
+ } else if (status.kind === "rejected") {
125
+ finish(null, status);
126
+ }
127
+ };
128
+ client.on("message", onMessage);
129
+ client.once("error", onError);
130
+ });
131
+ }
132
+
133
+ function parseStatus(payload) {
134
+ try {
135
+ const status = JSON.parse(payload.toString("utf8"));
136
+ if (status?.schema !== "weclawbot.device_status.v1") return null;
137
+ if (status.kind !== "applied" && status.kind !== "rejected") return null;
138
+ return {
139
+ schema: status.schema,
140
+ kind: status.kind,
141
+ device_id: string(status.device_id),
142
+ detail: string(status.detail),
143
+ };
144
+ } catch {
145
+ return null;
146
+ }
70
147
  }
71
148
 
72
149
  function onceConnected(client) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,6 +59,12 @@ OpenClaw Canvas for requests that mention WeClawBot, the physical screen, or
59
59
  not send raw text to firmware. The agent owns text layout, font choice, image
60
60
  rasterization, and page splitting; the device consumes pixels.
61
61
 
62
+ `weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
63
+ device status topic by default. Treat only `applied` as success. If the device
64
+ returns `rejected`, tell the user the real rejection reason. Use
65
+ `force_replace` or `weclawbotctl screen --force` only when the user explicitly
66
+ intends to overwrite the currently shown BYOA screen.
67
+
62
68
  Only return the normal WeChat decision shape below when processing an explicit
63
69
  `WECLAWBOT_CURATOR_EVENT` envelope.
64
70
 
@@ -37,6 +37,11 @@ content viewport, then publish it:
37
37
  weclawbotctl screen /path/to/screen-document.json
38
38
  ```
39
39
 
40
+ This command waits for firmware `applied`/`rejected` status by default. Report
41
+ success only after it exits with success. If the user explicitly asks to
42
+ replace whatever is currently shown, use `--force`; otherwise use the current
43
+ screen revision in `base_revision`.
44
+
40
45
  The firmware receives pixels. Do not send raw text to firmware, and do not
41
46
  answer that direct delivery is unavailable before checking the local
42
47
  `weclawbotctl` profile.