@openbrt/audioctl 0.1.6 → 0.1.7

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
@@ -52,6 +52,8 @@ npm exec --package @openbrt/audioctl -- audioctl bind 123456 --name codex
52
52
  ```
53
53
 
54
54
  Credentials are saved to `~/.config/weclawbot/audio-mqtt.json` by default.
55
+ `audioctl` serializes concurrent commands that use the same local MQTT
56
+ credentials, because the broker may require an exact MQTT client id.
55
57
 
56
58
  ## Commands
57
59
 
package/agent-plugin.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "description": "Bind and drive WeClawBot-compatible audio, voice, and ambient I/O devices from user-owned AI agents.",
8
8
  "transport": "weclawbot.link/mqtt",
9
9
  "user_contract": "用户只需要给出设备身份和目标;agent 负责选择合法平台 API、通过 agent 自己的连接器/OAuth/secret store 获取平台身份、构建最小权限脚本/队列、推送到设备 VM、诊断和报告结果。",
10
+ "runtime_note": "audioctl 会在本机自动串行化同一 MQTT 绑定凭据的并发控制命令,避免多个 agent 工具进程使用同一个 broker client_id 时互相踢下线。",
10
11
  "separation_of_concerns": {
11
12
  "provisioning": "蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定,不承载音乐平台授权,也不决定设备用途。",
12
13
  "music_platform_auth": "音乐平台授权属于 agent 侧能力,由 agent 的连接器、OAuth、secret store 或用户明确提供的运行环境凭据完成。",
package/lib/index.mjs CHANGED
@@ -719,24 +719,26 @@ export async function publishAudioControl(credentials, control, options = {}) {
719
719
  throw new Error("status_topic_missing");
720
720
  }
721
721
  const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
722
- const client = connectMqtt(profile, { timeoutMs });
723
- try {
724
- await onceConnected(client, timeoutMs);
725
- if (options.wait === false) {
722
+ return withMqttClientLock(profile.clientId, timeoutMs, async () => {
723
+ const client = connectMqtt(profile, { timeoutMs });
724
+ try {
725
+ await onceConnected(client, timeoutMs);
726
+ if (options.wait === false) {
727
+ await publishJson(client, profile.controlTopic, control);
728
+ return { ok: true, published: true, applied: null };
729
+ }
730
+ await subscribe(client, profile.statusTopic);
731
+ const statusPromise = waitForStatus(client, {
732
+ correlationId: control.id,
733
+ timeoutMs,
734
+ });
726
735
  await publishJson(client, profile.controlTopic, control);
727
- return { ok: true, published: true, applied: null };
736
+ const status = await statusPromise;
737
+ return { ok: status.kind === "applied", published: true, applied: true, status };
738
+ } finally {
739
+ client.end(true);
728
740
  }
729
- await subscribe(client, profile.statusTopic);
730
- const statusPromise = waitForStatus(client, {
731
- correlationId: control.id,
732
- timeoutMs,
733
- });
734
- await publishJson(client, profile.controlTopic, control);
735
- const status = await statusPromise;
736
- return { ok: status.kind === "applied", published: true, applied: true, status };
737
- } finally {
738
- client.end(true);
739
- }
741
+ });
740
742
  }
741
743
 
742
744
  export function remediationForStatus(status) {
@@ -993,6 +995,48 @@ function waitForStatus(client, options) {
993
995
  });
994
996
  }
995
997
 
998
+ async function withMqttClientLock(clientId, timeoutMs, task) {
999
+ const lockRoot = path.join(os.tmpdir(), "openbrt-audioctl-locks");
1000
+ await fs.mkdir(lockRoot, { recursive: true, mode: 0o700 });
1001
+ const lockName = crypto
1002
+ .createHash("sha256")
1003
+ .update(String(clientId || "default"))
1004
+ .digest("hex")
1005
+ .slice(0, 24);
1006
+ const lockDirectory = path.join(lockRoot, `${lockName}.lock`);
1007
+ const started = Date.now();
1008
+ while (true) {
1009
+ try {
1010
+ await fs.mkdir(lockDirectory, { mode: 0o700 });
1011
+ break;
1012
+ } catch (error) {
1013
+ if (error?.code !== "EEXIST") throw error;
1014
+ try {
1015
+ const stats = await fs.stat(lockDirectory);
1016
+ if (Date.now() - stats.mtimeMs > Math.max(60_000, timeoutMs * 3)) {
1017
+ await fs.rm(lockDirectory, { recursive: true, force: true });
1018
+ continue;
1019
+ }
1020
+ } catch {
1021
+ // Race with another process removing the lock. Try again.
1022
+ }
1023
+ if (Date.now() - started > timeoutMs) {
1024
+ throw new Error("mqtt_client_busy");
1025
+ }
1026
+ await sleep(120);
1027
+ }
1028
+ }
1029
+ try {
1030
+ return await task();
1031
+ } finally {
1032
+ await fs.rm(lockDirectory, { recursive: true, force: true });
1033
+ }
1034
+ }
1035
+
1036
+ function sleep(milliseconds) {
1037
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
1038
+ }
1039
+
996
1040
  function shellToken(value) {
997
1041
  const text = string(value) || "agent";
998
1042
  if (/^[A-Za-z0-9_./:@+-]+$/u.test(text)) return text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",