@agent-wechat/agent-wechat 1.0.0-beta.3 → 1.0.0-beta.5

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
@@ -21,7 +21,7 @@ Configure plugin defaults under `plugins.entries.agent-wechat.config` (or the sa
21
21
  }
22
22
  ```
23
23
 
24
- The external service remains independently owned. Plugin stop closes its stream and never stops/logs out the daemon or cancels its sends. Remote endpoints require verified HTTPS or an encrypted tunnel terminating at loopback. Set `AGENT_WECHAT_TOKEN` in the Gateway process environment, or use a configured OpenClaw secret provider. The plugin resolves `native.token` through the pinned OpenClaw SDK; `native.tokenFile` is removed. Tokens contain 32–1024 printable ASCII bytes without spaces. An explicit unresolved reference fails startup without falling back to another token. The API token needs read/media/send/login for the selected account, plus read-state only when automatic local read projection is enabled; do not grant admin solely to run the plugin. Normal sender/DM/group/mention policy remains in `channels.agent-wechat`; defaults remain closed.
24
+ The external service remains independently owned. Plugin stop closes its stream and never stops/logs out the daemon or cancels its sends. Remote endpoints require verified HTTPS or an encrypted tunnel terminating at loopback. Set `AGENT_WECHAT_TOKEN` in the Gateway process environment, or use a configured OpenClaw secret provider. The plugin resolves `native.token` through the pinned OpenClaw SDK; `native.tokenFile` is removed. Tokens contain 32–1024 printable ASCII bytes without spaces. An explicit unresolved reference fails startup without falling back to another token. The API token needs read/media/send/login for the selected account, plus read-state only when automatic whole-thread read marking is enabled; do not grant admin solely to run the plugin. Normal sender/DM/group/mention policy remains in `channels.agent-wechat`; defaults remain closed.
25
25
 
26
26
  ## Native managed mode
27
27
 
@@ -50,15 +50,15 @@ Keep `dataDir` and `consumerStateDir` on separate explicit persistent mounts. Th
50
50
 
51
51
  The adapter consumes account-scoped SSE using bearer headers and decimal-string journal cursors. It persists custody before acknowledging a cursor; acknowledgement is independent of unread state. Only explicitly live, incoming, committed resources can enter the model. Supported images, short voice, video, files and eligible stickers pass through bounded media retrieval and hash verification into OpenClaw-managed storage. Pending media retains custody without model dispatch or a read receipt. Expired/unsupported attachments retain an explicit status; raw XML never becomes an attachment. Catch-up/import/raw pending sync batches, unknown origin and outgoing messages do not become automatic model inputs. Group admission uses structured exact self-mention facts; unknown metadata or displayed `@names` never satisfy a mention gate. Contacts and groups use their separate catalogs.
52
52
 
53
- The prepared-run callback synchronously writes a bounded durable receipt for the exact selected new message IDs before a separate retryable read projection. Read CAS retries preserve the same sparse IDs and never widen to the largest ID or clear concurrent arrivals. `readPolicy: "never"` disables automatic reads. Crashes during an unconfirmed dispatch retain `needs_review`; the plugin does not automatically duplicate model turns or uncertain outgoing sends.
53
+ The prepared-run callback records the selected model input before marking its whole thread read. This also clears earlier unread messages in that thread, including messages the model did not receive. The daemon freezes the latest local message boundary, checks the expected version, updates local unread state and submits one native cross-device notification. A lost notification response remains `needs_review` and is never automatically resubmitted. `readPolicy: "never"` disables both local and cross-device marking. Crashes during an unconfirmed dispatch retain `needs_review`; the plugin does not automatically duplicate model turns or uncertain outgoing sends.
54
54
 
55
55
  OpenClaw **2026.10.2** lacks `onPreparedAgentRunStart`, so its released public hooks provide a bounded compatibility path. Before dispatch, the adapter persists an immutable manifest binding the exact new message ID/version/content hash, policy hash, source and supplied asset hashes, and context roles. Text requires the exact current input and host-created message/account/session facts plus a matching `model_call_started`. Media additionally waits for the same run's `agent_end` snapshot and source-turn identity. Image bytes/MIME/length must match the frozen supplied representation; file/video/voice references must appear in the exact current body. Image count, model selection and reply delivery alone cannot mark a message read. Unknown, omitted, rewritten, merged or ambiguous inputs remain unconfirmed.
56
56
 
57
- The compatibility path requires the operator's explicit host permission, `plugins.entries.agent-wechat.hooks.allowConversationAccess: true`. It observes only run IDs issued by this adapter and never enables that permission itself. Without the grant, or on an unsupported host, messages remain unread and status reports `handoffUnconfirmed`. The older `onAgentRunStart` also reports direct tools and early lifecycle phases and is never used as proof. `readPolicy: "never"` preserves the handoff receipt while disabling read projection. Late confirmed receipts retry their exact sparse reads independently of new SSE traffic.
57
+ The compatibility path requires the operator's explicit host permission, `plugins.entries.agent-wechat.hooks.allowConversationAccess: true`. It observes only run IDs issued by this adapter and never enables that permission itself. Without the grant, or on an unsupported host, messages remain unread and status reports `handoffUnconfirmed`. The older `onAgentRunStart` also reports direct tools and early lifecycle phases and is never used as proof. `readPolicy: "never"` preserves the handoff receipt while disabling read projection. Late confirmed receipts can mark their thread independently of new SSE traffic. Access and mention gates still precede model dispatch; a rejected message does not itself trigger a read.
58
58
 
59
59
  Images exceeding the host's bounded representation, or formats requiring conversion, receive a separately stored JPEG still preview using the public SDK image processor. The original stays intact; both hashes are recorded and the model input labels the preview. Prepared images fit within 5 MiB and at most 1200 pixels per side (or a smaller configured limit). Files and videos can be supplied as available local references; this does not claim their contents were analyzed. Voice retains its verified file and includes configured transcription output or an explicit unavailable status. Pending, expired, unsupported and failed-integrity media never gains a read receipt from a placeholder.
60
60
 
61
- Run `node scripts/native-model-smoke.mjs` and `node scripts/native-media-model-smoke.mjs` after installing workspace dependencies. They use the actual pinned Gateway, a local model and synthetic daemon/files. Checks cover current provider input, image hashes and prepared previews, reference/transcription delivery, exact sparse reads, lost-read-response/reopen recovery, duplicate prevention, omitted images on a text-only model, permission, never-read policy and group routing behavior. Transcription uses the real SDK with an explicitly configured local deterministic CLI; it does not test speech recognition accuracy. Concurrent text inputs were serialized into distinct model turns; merged-input refusal has unit coverage. These checks establish the prepared runtime boundary, not external provider acceptance, semantic analysis or consumption.
61
+ Run `node scripts/native-model-smoke.mjs` and `node scripts/native-media-model-smoke.mjs` after installing workspace dependencies. They use the actual pinned Gateway, a local model and synthetic daemon/files. Checks cover current provider input, image hashes and prepared previews, reference/transcription delivery, whole-thread read requests, lost-response/reopen recovery, duplicate prevention, omitted images on a text-only model, permission, never-read policy and group routing behavior. Transcription uses the real SDK with an explicitly configured local deterministic CLI; it does not test speech recognition accuracy. Concurrent text inputs were serialized into distinct model turns; merged-input refusal has unit coverage. These checks establish the prepared runtime boundary, not external provider acceptance, semantic analysis or consumption.
62
62
 
63
63
  Native text/media sends retain daemon intent/job identity and caller idempotency. Text waits for accepted submission; media jobs preserve partial and uncertain outcomes rather than resending. Ordinary audio remains a file; only explicit `audioAsVoice` requests independently encoded voice. Sender/group/mention gates run before retrieval/transcription. Verified voice may use the configured OpenClaw transcription runtime after admission. Native QR login uses a durable daemon operation and authenticated PNG retrieval. Logout commits local inhibition before at most one authenticated native logout, reporting the remote outcome separately; saved-device revocation remains unverified. Explicitly confirmed forgetting removes local auth while retaining history; disconnect retains reusable trust.
64
64
 
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ var nativeConfigSchema = {
22
22
  }
23
23
  }] },
24
24
  consumerStateDir: { type: "string" },
25
- readPolicy: { type: "string", enum: ["never", "on_model_dispatch"] },
25
+ readPolicy: { type: "string", enum: ["never", "on_inbound", "on_model_dispatch"] },
26
26
  gatewayFacade: { type: "object", additionalProperties: false, required: ["enabled"], properties: { enabled: { type: "boolean" }, prefix: { type: "string" } } }
27
27
  }
28
28
  };
@@ -169,6 +169,7 @@ var NativeWeChatClient = class {
169
169
  token;
170
170
  fetchImpl;
171
171
  timeoutMs;
172
+ streamIdleTimeoutMs;
172
173
  constructor(options) {
173
174
  this.baseUrl = validateNativeBaseUrl(options.baseUrl);
174
175
  const nodeEnvironment = globalThis.process?.env;
@@ -179,6 +180,9 @@ var NativeWeChatClient = class {
179
180
  this.token = options.token;
180
181
  this.fetchImpl = options.fetch ?? fetch;
181
182
  this.timeoutMs = options.timeoutMs ?? 3e4;
183
+ this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 45e3;
184
+ if (!Number.isInteger(this.streamIdleTimeoutMs) || this.streamIdleTimeoutMs < 1 || this.streamIdleTimeoutMs > 3e5)
185
+ throw new Error("Invalid native stream idle timeout");
182
186
  }
183
187
  async response(path, init = {}, stream = false) {
184
188
  const headers = new Headers(init.headers);
@@ -436,16 +440,10 @@ var NativeWeChatClient = class {
436
440
  }
437
441
  return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), status: response.status };
438
442
  }
439
- markRead(accountId, threadId, messageIds, expectedVersion) {
440
- if (!messageIds.length || messageIds.length > 200 || messageIds.some((value) => typeof value !== "string" || !value) || typeof expectedVersion !== "string")
441
- throw new Error("Invalid exact-ID read projection");
442
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ messageIds: [...new Set(messageIds)], expectedVersion }) });
443
- }
444
- markReadThrough(accountId, threadId, throughMessageId, expectedVersion) {
445
- id(throughMessageId);
446
- if (typeof expectedVersion !== "string" || !expectedVersion)
443
+ markRead(accountId, threadId, expectedVersion) {
444
+ if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
447
445
  throw new Error("Read version is required");
448
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ throughMessageId, expectedVersion }) });
446
+ return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
449
447
  }
450
448
  events(accountId, after = "0", limit = 100) {
451
449
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
@@ -456,12 +454,27 @@ var NativeWeChatClient = class {
456
454
  return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
457
455
  }
458
456
  async *watch(accountId, after = "0", signal) {
459
- const response = await this.response(`v1/accounts/${id(accountId)}/events/stream?after=${decimal(after)}`, { signal, headers: { Accept: "text/event-stream" } }, true);
460
- if (!response.headers.get("content-type")?.startsWith("text/event-stream"))
461
- throw new Error("Expected native SSE response");
462
- const reader = response.body?.getReader();
463
- if (!reader)
464
- throw new Error("Native SSE has no body");
457
+ const connection = new AbortController();
458
+ const streamSignal = signal ? AbortSignal.any([signal, connection.signal]) : connection.signal;
459
+ const bounded = async (work, milliseconds, reason) => {
460
+ if (streamSignal.aborted)
461
+ throw streamSignal.reason;
462
+ let timer;
463
+ let onAbort = () => {
464
+ };
465
+ const interrupted = new Promise((_resolve, reject) => {
466
+ onAbort = () => reject(streamSignal.reason ?? new Error("Native SSE cancelled"));
467
+ streamSignal.addEventListener("abort", onAbort, { once: true });
468
+ timer = setTimeout(() => connection.abort(new Error(reason)), milliseconds);
469
+ });
470
+ try {
471
+ return await Promise.race([work(), interrupted]);
472
+ } finally {
473
+ clearTimeout(timer);
474
+ streamSignal.removeEventListener("abort", onAbort);
475
+ }
476
+ };
477
+ let reader;
465
478
  const decoder = new TextDecoder();
466
479
  let buffer = "";
467
480
  let data = [];
@@ -469,8 +482,17 @@ var NativeWeChatClient = class {
469
482
  let eventCursor = "";
470
483
  let eventType = "";
471
484
  try {
485
+ const response = await bounded(() => this.response(`v1/accounts/${id(accountId)}/events/stream?after=${decimal(after)}`, { signal: streamSignal, headers: { Accept: "text/event-stream" } }, true), this.timeoutMs, "Native SSE connection timeout");
486
+ if (!response.headers.get("content-type")?.startsWith("text/event-stream")) {
487
+ await response.body?.cancel();
488
+ throw new Error("Expected native SSE response");
489
+ }
490
+ reader = response.body?.getReader();
491
+ if (!reader)
492
+ throw new Error("Native SSE has no body");
472
493
  while (true) {
473
- const { value, done } = await reader.read();
494
+ const activeReader = reader;
495
+ const { value, done } = await bounded(() => activeReader.read(), this.streamIdleTimeoutMs, "Native SSE idle timeout");
474
496
  if (done)
475
497
  break;
476
498
  buffer += decoder.decode(value, { stream: true });
@@ -510,9 +532,12 @@ var NativeWeChatClient = class {
510
532
  }
511
533
  }
512
534
  } finally {
513
- await reader.cancel().catch(() => {
514
- });
515
- reader.releaseLock();
535
+ connection.abort();
536
+ if (reader) {
537
+ await reader.cancel().catch(() => {
538
+ });
539
+ reader.releaseLock();
540
+ }
516
541
  }
517
542
  }
518
543
  };
@@ -709,7 +734,7 @@ function collectWeChatStatusIssues(accounts) {
709
734
  accountId: snapshot.accountId,
710
735
  kind: "runtime",
711
736
  message: "Prepared model handoff was not confirmed by this OpenClaw host; automatic read state remains unchanged.",
712
- fix: "Use a compatible prepared-run SDK hook, or select native.readPolicy=never while retaining explicit local read control."
737
+ fix: "Select native.readPolicy=on_inbound to mark successfully handled conversations read without model hooks, or use a compatible prepared-run SDK hook."
713
738
  });
714
739
  }
715
740
  if ((snapshot.needsReview ?? 0) > 0) {
@@ -1333,7 +1358,7 @@ var NativeConsumerState = class {
1333
1358
  if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
1334
1359
  this.mutate(() => {
1335
1360
  if (message && !this.value.items[message.id]) {
1336
- const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && ["completed", "ignored"].includes(item.state));
1361
+ const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
1337
1362
  for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
1338
1363
  if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1339
1364
  this.value.items[message.id] = { message, origin, state: "received" };
@@ -1548,14 +1573,16 @@ function nativeWasMentioned(message) {
1548
1573
  return message.mentions?.state === "known" && message.mentions.self === true;
1549
1574
  }
1550
1575
  async function projectNativeRead(client, accountId, state) {
1551
- for (const item of state.items().filter((value) => value.readPending && value.runId)) {
1576
+ const pending2 = state.items().filter((value) => value.readPending);
1577
+ const threads = [...new Set(pending2.map((item) => item.message.threadId))];
1578
+ for (const threadId of threads) {
1552
1579
  for (let attempt = 0; attempt < 2; attempt++) {
1553
1580
  let cursor;
1554
1581
  let thread;
1555
1582
  const seen = /* @__PURE__ */ new Set();
1556
1583
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
1557
1584
  const page = await client.threads(accountId, cursor);
1558
- thread = page.items.find((value) => !isNativeTombstone(value) && value.id === item.message.threadId);
1585
+ thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId);
1559
1586
  if (thread || !page.nextCursor) break;
1560
1587
  if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
1561
1588
  seen.add(page.nextCursor);
@@ -1563,8 +1590,10 @@ async function projectNativeRead(client, accountId, state) {
1563
1590
  }
1564
1591
  if (!thread) break;
1565
1592
  try {
1566
- await client.markRead(accountId, thread.id, [item.message.id], thread.readStateVersion);
1567
- state.update(item.message.id, { readPending: false });
1593
+ const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
1594
+ const notification = receipt?.phoneNotification?.state;
1595
+ const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
1596
+ for (const item of pending2.filter((item2) => item2.message.threadId === threadId)) state.update(item.message.id, { readPending: false, phoneReadState });
1568
1597
  break;
1569
1598
  } catch (error) {
1570
1599
  if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
@@ -1575,6 +1604,7 @@ async function projectNativeRead(client, accountId, state) {
1575
1604
  }
1576
1605
  async function dispatchNativeMessage(params) {
1577
1606
  const { client, state, account, message, cfg, core } = params;
1607
+ const readPolicy = account.native?.readPolicy ?? "on_inbound";
1578
1608
  const custody = state.get(message.id);
1579
1609
  if (!custody || custody.state !== "received") return "needs_review";
1580
1610
  if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
@@ -1669,7 +1699,7 @@ async function dispatchNativeMessage(params) {
1669
1699
  assets: preparedMedia.suppliedAssets ?? []
1670
1700
  });
1671
1701
  state.update(message.id, { state: "dispatching", dispatchManifest });
1672
- const callback = message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length ? state.preparedCallback([message.id], account.native?.readPolicy ?? "on_model_dispatch") : void 0;
1702
+ const callback = readPolicy !== "on_inbound" && (message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
1673
1703
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
1674
1704
  let failed = false;
1675
1705
  let delivery = 0;
@@ -1691,6 +1721,7 @@ async function dispatchNativeMessage(params) {
1691
1721
  const key = createHash8("sha256").update(`${account.native.accountId}\0${message.id}\0${delivery++}`).digest("hex");
1692
1722
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
1693
1723
  }
1724
+ if (urls.length || payload.text) params.onDelivered?.();
1694
1725
  },
1695
1726
  onError: () => {
1696
1727
  failed = true;
@@ -1702,8 +1733,12 @@ async function dispatchNativeMessage(params) {
1702
1733
  // and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
1703
1734
  replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
1704
1735
  });
1705
- const prepared = Boolean(state.get(message.id)?.runId);
1706
- state.update(message.id, { state: failed ? "needs_review" : "completed", handoffUnconfirmed: !prepared });
1736
+ const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
1737
+ state.update(message.id, {
1738
+ state: failed ? "needs_review" : "completed",
1739
+ readPending,
1740
+ handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
1741
+ });
1707
1742
  return failed ? "needs_review" : "completed";
1708
1743
  } catch {
1709
1744
  state.update(message.id, { state: "needs_review" });
@@ -1737,23 +1772,34 @@ async function startNativeMonitor(opts) {
1737
1772
  const core = getWeChatRuntime();
1738
1773
  let backoff = 250;
1739
1774
  let readTask;
1740
- const flushRead = () => readTask ??= projectNativeRead(host2.client, native.accountId, state).finally(() => {
1741
- readTask = void 0;
1742
- });
1775
+ const flushRead = () => {
1776
+ if (native.readPolicy === "never") return Promise.resolve();
1777
+ return readTask ??= projectNativeRead(host2.client, native.accountId, state).finally(() => {
1778
+ readTask = void 0;
1779
+ });
1780
+ };
1743
1781
  const process2 = async () => {
1744
1782
  const cfg = core.config.current();
1745
1783
  const account = resolveWeChatAccount(cfg, opts.account.accountId) ?? opts.account;
1746
1784
  if (account.backend !== "native" || JSON.stringify(account.native) !== JSON.stringify(native)) throw new Error("Native service configuration changed; restart required before processing");
1747
1785
  for (const item of state.items().filter((value) => value.state === "received")) {
1748
- await dispatchNativeMessage({ client: host2.client, state, account, message: item.message, cfg, core });
1786
+ await dispatchNativeMessage({
1787
+ client: host2.client,
1788
+ state,
1789
+ account,
1790
+ message: item.message,
1791
+ cfg,
1792
+ core,
1793
+ onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
1794
+ });
1749
1795
  }
1750
1796
  try {
1751
1797
  await flushRead();
1752
1798
  } catch {
1753
- opts.log?.error?.("Native read projection is pending; exact input IDs retained for retry");
1799
+ opts.log?.error?.("Native read projection is pending; thread read receipt retained for retry");
1754
1800
  }
1755
- const needsReview = state.items().filter((value) => value.state === "needs_review").length;
1756
- const handoffUnconfirmed = native.readPolicy !== "never" && state.items().some((value) => value.handoffUnconfirmed);
1801
+ const needsReview = state.items().filter((value) => value.state === "needs_review" || value.phoneReadState === "needs_review" || value.phoneReadState === "rejected").length;
1802
+ const handoffUnconfirmed = native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed);
1757
1803
  const accountStatus = await host2.client.account(native.accountId);
1758
1804
  opts.setStatus({
1759
1805
  accountId: account.accountId,
@@ -1769,9 +1815,9 @@ async function startNativeMonitor(opts) {
1769
1815
  await flushRead();
1770
1816
  if (!opts.abortSignal.aborted) opts.setStatus({
1771
1817
  accountId: opts.account.accountId,
1772
- handoffUnconfirmed: native.readPolicy !== "never" && state.items().some((value) => value.handoffUnconfirmed)
1818
+ handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
1773
1819
  });
1774
- }, () => opts.log?.error?.("Native read projection remains pending; exact input IDs retained"));
1820
+ }, () => opts.log?.error?.("Native read projection remains pending; thread read receipts retained"));
1775
1821
  try {
1776
1822
  while (!opts.abortSignal.aborted) {
1777
1823
  try {
@@ -1785,6 +1831,7 @@ async function startNativeMonitor(opts) {
1785
1831
  if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
1786
1832
  }
1787
1833
  state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
1834
+ if (message?.direction === "incoming" && event.data.origin === "live") opts.setStatus({ accountId: opts.account.accountId, lastInboundAt: Date.now() });
1788
1835
  await host2.client.ack(native.accountId, "openclaw", state.cursor);
1789
1836
  await process2();
1790
1837
  backoff = 250;
@@ -2322,7 +2369,8 @@ var plugin = {
2322
2369
  api.registerChannel({ plugin: wechatPlugin });
2323
2370
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
2324
2371
  };
2325
- const stopPreparedBoundary = registerNativePreparedBoundary(api);
2372
+ const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
2373
+ };
2326
2374
  api.registerService({
2327
2375
  id: "agent-wechat-native",
2328
2376
  reload: { configPrefixes: ["plugins.entries.agent-wechat.config", "channels.agent-wechat", "secrets"] },
@@ -95,6 +95,7 @@
95
95
  "type": "string",
96
96
  "enum": [
97
97
  "never",
98
+ "on_inbound",
98
99
  "on_model_dispatch"
99
100
  ]
100
101
  },
@@ -274,6 +275,7 @@
274
275
  "type": "string",
275
276
  "enum": [
276
277
  "never",
278
+ "on_inbound",
277
279
  "on_model_dispatch"
278
280
  ]
279
281
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-wechat/agent-wechat",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  "esbuild": "^0.25.0",
12
12
  "openclaw": "2026.10.2",
13
13
  "typescript": "^5.4.5",
14
- "@agent-wechat/shared": "1.0.0-beta.0"
14
+ "@agent-wechat/shared": "1.0.0-beta.1"
15
15
  },
16
16
  "openclaw": {
17
17
  "extensions": [
@@ -183,6 +183,7 @@
183
183
  "type": "string",
184
184
  "enum": [
185
185
  "never",
186
+ "on_inbound",
186
187
  "on_model_dispatch"
187
188
  ]
188
189
  },