@gavin-hu/my-pi-agent 0.11.2 → 0.11.4

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.
@@ -61,7 +61,9 @@ How the `send_wechat` call and its result look in the transcript — the call li
61
61
  can drive the agent; `allowedPeers` opts in others. An entry matches either id
62
62
  form (`alice` or `alice@im.wechat`), and a refused sender is named in
63
63
  `/wechat status` and in one terminal notice, so the id to allowlist can be
64
- copied from either.
64
+ copied from either. `a` on the control screen or `/wechat allow [peer]` writes
65
+ one entry into the project config; the running bridge picks it up on the next
66
+ `/reload` or restart, and the confirmation names the file.
65
67
  - **Session-bound** — the bridge runs while an account is stored and the Pi
66
68
  process is alive; it starts on `session_start`, and `/wechat logout` clears the
67
69
  account and stops it. The peer sessions outlive it on disk.
@@ -74,8 +76,13 @@ How the `send_wechat` call and its result look in the transcript — the call li
74
76
  | `/wechat login` | QR login; on scan, stores credentials, replaces a running poller, and starts the bridge. |
75
77
  | `/wechat new` | Start a fresh *host* session. Peer conversations are independent and survive it; they use an inbound `/new`. |
76
78
  | `/wechat status` | The same control screen as a bare `/wechat` (the one-line summary while a peer turn runs). |
79
+ | `/wechat pause` | Stop polling new messages; running turns may finish and reply. The poller lock is released, so another runtime may take the account. |
80
+ | `/wechat resume` | Reacquire the lock and poll again; reports when another runtime already holds it. |
81
+ | `/wechat doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
77
82
  | `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
78
83
  | `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
84
+ | `/wechat history <peer> [n]` | Print that peer's recent messages (default 10, max 20); read-only, and works without a live session. |
85
+ | `/wechat allow [peer]` | Add a refused sender to `wechat.allowedPeers` in the project `.pi/bytefly.json`, after a confirmation that names the file. Without an argument it offers the refused senders. |
79
86
  | `/wechat logout` | Confirm, then clear credentials, drop the bridge, and stop polling. |
80
87
  | `/wechat help` | List the subcommands with a line each. |
81
88
 
@@ -98,10 +105,23 @@ one-step recovery.
98
105
 
99
106
  In the TUI, `/wechat` opens a control screen with the account, connection
100
107
  state, owner, per-peer conversation rows (what each peer is doing, how long, and
101
- what is queued behind it), activity counters, and any pending remote prompt. Keys: `l` log in
102
- or reconnect, `n` start a new session, `o` log out, `r` refresh, and Esc closes.
103
- The subcommands above are shortcuts for the same actions and the only route
104
- outside the TUI; an unknown subcommand reports the valid ones.
108
+ what is queued behind it), activity counters, and the waiting remote prompt
109
+ (peer, question, and age).
110
+ `j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
111
+ peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
112
+ the screen and prints that peer's history; `a` closes it and offers the refused
113
+ senders to allow; `c` withdraws the waiting prompt; `p` pauses or resumes
114
+ polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
115
+ `r` refreshes, and Esc closes. The subcommands above are shortcuts for the same
116
+ actions and the only route outside the TUI; an unknown subcommand reports the
117
+ valid ones.
118
+
119
+ Pause is per runtime and keeps the account and peer sessions: a running turn
120
+ may finish and reply, a waiting prompt is withdrawn with the timeout notice,
121
+ and the poller lock is released so another runtime can take over. Resume
122
+ reacquires the lock and reports when another runtime holds it. `/reload` or a
123
+ restart builds a new bridge and resumes polling, so pause is a temporary mute,
124
+ not a persistent switch.
105
125
 
106
126
  A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
107
127
  command typed at the terminal declines while one is running: `status` prints the
@@ -304,6 +324,9 @@ never touches the stored cursor.
304
324
 
305
325
  ## Troubleshooting
306
326
 
327
+ - **First, run `/wechat doctor`.** It prints the connection, token age, last
328
+ poll, poller lock, per-peer queue/session age, and the last failure, which
329
+ answers most "why is the bot quiet?" questions in one screen.
307
330
  - **`Another Pi session is already polling this WeChat account.`** — one runtime
308
331
  owns the account at a time, tracked by `<agent-dir>/wechat/poller.lock`.
309
332
  Quit the other `pi` process. A lock left by a process that already exited is
@@ -382,16 +405,16 @@ sessions are in-process).
382
405
 
383
406
  | Integration point | Detail |
384
407
  |---|---|
385
- | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `stop <peer>`, `reset <peer>`, `logout`, `help`; bare `/wechat` opens the control screen. |
408
+ | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
386
409
  | Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
387
410
  | Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
388
411
  | Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
389
412
  | Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
390
413
  | Slash commands | Admitted inbound text is matched against `SLASH_COMMANDS` in `handleMessage`; a snippet is expanded into the turn, an action runs out-of-band and replies, and an unknown command falls through. Actions act on the sender through `SlashSessionControls` (`newSession` → worker reset, `stop` → worker abort, `status` → worker state, `history` → a read-only read of that peer's newest session). |
391
414
  | Peer dialogs | `bindExtensions({ mode: "rpc", uiContext })` on each peer session, with `createPeerUIContext(channel)` routing `select`/`confirm`/`input`/`editor` to that peer and marking the context for `askHuman`'s file/text preface. The terminal's shared `ctx.ui` is deliberately not adapted: a host turn is never answered remotely, so its dialogs stay local even while a peer turn runs. |
392
- | Status | The bridge's connection state (`running` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, and the status bar renders it on line 2. |
415
+ | Status | The bridge's connection state (`running` / `paused` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, pause keeps a warning chip, and the status bar renders it on line 2. |
393
416
  | State | Files under `<agent-dir>/wechat/`; the bridge is not authoritative UI, so nothing is stored in the transcript. |
394
- | Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running (so the RPC double `session_start` on a session switch cannot start a second poller), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
417
+ | Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running or paused (so the RPC double `session_start` on a session switch cannot start a second poller or resume a paused one), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `pause()` stops the loop and releases the lock while keeping the worker, so a running turn can finish; `resume()` reacquires the lock and starts a new loop. Pause is runtime-local, so a reload or restart starts a fresh running bridge. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
395
418
 
396
419
  ## Design notes
397
420
 
@@ -483,7 +506,7 @@ status, which is what the old start/stop guidance used to do.
483
506
  | `login.ts` | QR login state machine. |
484
507
  | `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
485
508
  | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
486
- | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`) and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `paint`). |
509
+ | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
487
510
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
488
511
  | `credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
489
512
  | `state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
@@ -497,6 +520,7 @@ status, which is what the old start/stop guidance used to do.
497
520
  | `remote-ui.ts` | The peer session's UI context: dialogs and notices bound to that peer, the `askHuman` marker that delivers the file/text preface, and the `remotePrompts` switch. |
498
521
  | `slash.ts` | Inbound slash commands: the built-in registry (`/new`, `/stop`, `/status`, `/history`, `/help`), the session controls an action may invoke, and the pure parse/lookup/expand helpers. |
499
522
  | `history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
523
+ | `doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
500
524
  | `types.ts` | Wire and domain types. |
501
525
 
502
526
  ## Testing
@@ -25,7 +25,11 @@ import { messageOf } from "./errors.ts";
25
25
  import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./format.ts";
26
26
  import { notice } from "./notices.ts";
27
27
  import { createPresence } from "./presence.ts";
28
- import { createWechatInteractionChannel, type WechatInteractionChannel } from "./interaction.ts";
28
+ import {
29
+ createWechatInteractionChannel,
30
+ type PendingPromptInfo,
31
+ type WechatInteractionChannel,
32
+ } from "./interaction.ts";
29
33
  import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
30
34
  import { createPeerWorker, type PeerState, type PeerWorker } from "./peer-worker.ts";
31
35
  import type { PeerSession } from "./peer-session.ts";
@@ -61,7 +65,7 @@ export function isInboundMessage(message: WeixinMessage): message is WeixinMessa
61
65
  }
62
66
 
63
67
  /** The bridge's connection state, published to the UI for the status chip. */
64
- export type BridgeState = "stopped" | "running" | "expired";
68
+ export type BridgeState = "stopped" | "running" | "expired" | "paused";
65
69
 
66
70
  export interface BridgeDeps {
67
71
  config: WechatConfig;
@@ -95,12 +99,16 @@ export interface BridgeDeps {
95
99
  notify?: (message: string, kind?: "info" | "warning" | "error") => void;
96
100
  /** Called whenever the bridge's connection state changes, for a status chip. */
97
101
  onStateChange?: (state: BridgeState) => void;
102
+ /** Read the poller lock, for the doctor. */
103
+ loadLock?: () => { pid: number; at: number } | undefined;
98
104
  }
99
105
 
100
106
  export interface BridgeStatus {
101
107
  open: boolean;
102
108
  /** Whether the bridge stopped because the session expired and needs a re-login. */
103
109
  expired: boolean;
110
+ /** Whether the operator paused polling; the account and peer sessions are kept. */
111
+ paused: boolean;
104
112
  owner?: string;
105
113
  peerCount: number;
106
114
  queued: number;
@@ -114,6 +122,22 @@ export interface BridgeStatus {
114
122
  mediaDropped: number;
115
123
  /** Whether a remote dialog prompt is waiting for an answer. */
116
124
  promptPending: boolean;
125
+ /** The waiting prompt's peer, kind, title, and age. */
126
+ prompt?: PendingPromptInfo;
127
+ /** When this connection opened. */
128
+ startedAt?: number;
129
+ /** When the last poll settled, successful or not. */
130
+ lastPollAt?: number;
131
+ /** The last poll failure, cleared by a successful poll. */
132
+ lastPollError?: string;
133
+ /** The last outbound send failure, cleared by a successful send. */
134
+ lastSendError?: string;
135
+ /** The long-poll hold currently in use (server hint or configured). */
136
+ pollHoldMs?: number;
137
+ /** The stored account's login time. */
138
+ tokenCreatedAt?: number;
139
+ /** The poller lock on disk, for the doctor. */
140
+ lock?: { pid: number; at: number };
117
141
  }
118
142
 
119
143
  export interface SendResult {
@@ -124,6 +148,10 @@ export interface SendResult {
124
148
  export interface Bridge {
125
149
  open(): void;
126
150
  close(): Promise<void>;
151
+ /** Stop polling new messages; running turns may finish. False when not running. */
152
+ pause(): Promise<boolean>;
153
+ /** Reacquire the lock and start polling again. False when not paused or the lock is taken. */
154
+ resume(): Promise<boolean>;
127
155
  shutdown(): Promise<void>;
128
156
  /** Stop one peer's running turn; false when that peer is idle. */
129
157
  stop(peer: string): boolean;
@@ -143,7 +171,13 @@ export function createBridge(deps: BridgeDeps): Bridge {
143
171
  const sleep = deps.sleep ?? defaultSleep;
144
172
  const armTimer = deps.armTimer ?? defaultTimer;
145
173
  let running = false;
174
+ let paused = false;
146
175
  let expired = false;
176
+ let startedAt: number | undefined;
177
+ let lastPollAt: number | undefined;
178
+ let lastPollError: string | undefined;
179
+ let lastSendError: string | undefined;
180
+ let pollHoldMs: number | undefined;
147
181
  let abort: AbortController | undefined;
148
182
  let loopPromise: Promise<void> | undefined;
149
183
  let client: WechatClient | undefined;
@@ -168,11 +202,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
168
202
 
169
203
  /** Report the current connection state to the host, for a status chip. */
170
204
  function publishState(): void {
171
- deps.onStateChange?.(running ? "running" : expired ? "expired" : "stopped");
205
+ deps.onStateChange?.(running ? "running" : expired ? "expired" : paused ? "paused" : "stopped");
172
206
  }
173
207
 
174
208
  function handleExpired(): void {
175
209
  running = false;
210
+ paused = false;
176
211
  expired = true;
177
212
  // An expiry can be discovered by a send while the loop is parked in
178
213
  // `getUpdates`; abort it so it cannot wake up beside a later `open()`.
@@ -204,6 +239,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
204
239
  notify,
205
240
  onExpired: handleExpired,
206
241
  forgetToken,
242
+ onSendError: (message) => {
243
+ lastSendError = message;
244
+ },
245
+ onSent: () => {
246
+ lastSendError = undefined;
247
+ },
207
248
  });
208
249
 
209
250
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
@@ -420,6 +461,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
420
461
  refreshLock: deps.refreshLock,
421
462
  isRunning: () => running,
422
463
  pollTimeout,
464
+ now: deps.now,
465
+ onPoll: (result) => {
466
+ lastPollAt = result.at;
467
+ lastPollError = result.error;
468
+ if (result.holdMs !== undefined) pollHoldMs = result.holdMs;
469
+ },
423
470
  });
424
471
 
425
472
  /**
@@ -438,6 +485,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
438
485
  budget.reset();
439
486
  admission.reset();
440
487
  inbound.reset();
488
+ lastPollAt = undefined;
489
+ lastPollError = undefined;
490
+ lastSendError = undefined;
491
+ pollHoldMs = undefined;
441
492
  }
442
493
 
443
494
  function releaseSafe(): void {
@@ -448,6 +499,21 @@ export function createBridge(deps: BridgeDeps): Bridge {
448
499
  }
449
500
  }
450
501
 
502
+ /**
503
+ * Serialize lifecycle transitions. A pause and a resume from the control
504
+ * screen, or a shutdown racing either, must not leave two poll loops or a
505
+ * released lock behind a running bridge.
506
+ */
507
+ let lifecycle: Promise<void> = Promise.resolve();
508
+ function serial<T>(work: () => Promise<T>): Promise<T> {
509
+ const run = lifecycle.then(work, work);
510
+ lifecycle = run.then(
511
+ () => undefined,
512
+ () => undefined,
513
+ );
514
+ return run;
515
+ }
516
+
451
517
  return {
452
518
  open(): void {
453
519
  if (running) return;
@@ -480,8 +546,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
480
546
  deps.saveState(state);
481
547
  }
482
548
  running = true;
549
+ paused = false;
483
550
  expired = false;
484
551
  resetEphemeral();
552
+ startedAt = deps.now();
485
553
  // One worker per connection: it owns the per-peer queues and sessions, so
486
554
  // a login (or a reconnect after an expiry) starts from a clean slate while
487
555
  // each peer's history stays on disk.
@@ -519,28 +587,71 @@ export function createBridge(deps: BridgeDeps): Bridge {
519
587
  });
520
588
  },
521
589
 
522
- async close(): Promise<void> {
523
- if (!running && !abort) {
590
+ close(): Promise<void> {
591
+ return serial(async () => {
592
+ running = false;
593
+ paused = false;
594
+ expired = false;
595
+ abort?.abort();
596
+ abort = undefined;
597
+ try {
598
+ await loopPromise;
599
+ } catch {
600
+ // Errors are surfaced by the loop's own catch.
601
+ }
602
+ loopPromise = undefined;
603
+ // A paused bridge has no loop and no abort controller but still owns a
604
+ // worker, so close it unconditionally rather than leaking the sessions.
605
+ const closing = worker;
606
+ worker = undefined;
607
+ await closing?.close();
608
+ resetEphemeral();
609
+ publishState();
610
+ releaseSafe();
611
+ });
612
+ },
613
+
614
+ pause(): Promise<boolean> {
615
+ return serial(async () => {
616
+ if (!running) return false;
617
+ running = false;
618
+ paused = true;
619
+ // Clear the abort controller before withdrawing the prompt: its send must
620
+ // not be cancelled by the poll abort that follows.
621
+ const stopping = abort;
622
+ abort = undefined;
623
+ if (interaction.hasPending()) interaction.cancel();
624
+ stopping?.abort();
625
+ try {
626
+ await loopPromise;
627
+ } catch {
628
+ // Errors are surfaced by the loop's own catch.
629
+ }
630
+ loopPromise = undefined;
631
+ // No poller means no lock to keep: another runtime may take the account
632
+ // while this one is paused. Resume reacquires it and reports a conflict.
524
633
  releaseSafe();
525
634
  publishState();
526
- return;
527
- }
528
- running = false;
529
- expired = false;
530
- abort?.abort();
531
- abort = undefined;
532
- try {
533
- await loopPromise;
534
- } catch {
535
- // Errors are surfaced by the loop's own catch.
536
- }
537
- loopPromise = undefined;
538
- const closing = worker;
539
- worker = undefined;
540
- await closing?.close();
541
- resetEphemeral();
542
- publishState();
543
- releaseSafe();
635
+ return true;
636
+ });
637
+ },
638
+
639
+ resume(): Promise<boolean> {
640
+ return serial(async () => {
641
+ if (!paused || expired || !worker || !account) return false;
642
+ if (!deps.acquireLock()) {
643
+ notify("Another Pi session is already polling this WeChat account.", "warning");
644
+ return false;
645
+ }
646
+ paused = false;
647
+ running = true;
648
+ abort = new AbortController();
649
+ loopPromise = poll.run(abort.signal).catch((error) => {
650
+ notify(`WeChat bridge stopped: ${messageOf(error)}`, "error");
651
+ });
652
+ publishState();
653
+ return true;
654
+ });
544
655
  },
545
656
 
546
657
  async shutdown(): Promise<void> {
@@ -565,6 +676,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
565
676
  peerStates: states,
566
677
  open: running,
567
678
  expired,
679
+ paused,
568
680
  owner: state.ownerId ?? account?.ilinkUserId,
569
681
  peerCount: Object.keys(state.peers).length,
570
682
  queued: states.reduce((total, entry) => total + entry.state.queued, 0),
@@ -573,6 +685,14 @@ export function createBridge(deps: BridgeDeps): Bridge {
573
685
  refusedPeers: [...refusedPeers],
574
686
  mediaDropped: inbound.dropped(),
575
687
  promptPending: interaction.hasPending(),
688
+ prompt: interaction.pendingInfo(),
689
+ startedAt,
690
+ lastPollAt,
691
+ lastPollError,
692
+ lastSendError,
693
+ pollHoldMs: pollHoldMs ?? deps.config.pollHoldMs,
694
+ tokenCreatedAt: account?.createdAt,
695
+ lock: deps.loadLock?.(),
576
696
  };
577
697
  },
578
698
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `/wechat` command: `login`, `new`, `status`, `logout`, `help`.
2
+ * The `/wechat` command: `login`, `new`, `status`, `logout`, `allow`, `help`.
3
3
  *
4
4
  * The command is the operator's single entry point. `/wechat` (or `/wechat
5
5
  * status`) opens the control screen, where every action is a key; the verb
@@ -11,11 +11,20 @@
11
11
  * declines while a WeChat peer turn is running.
12
12
  */
13
13
 
14
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ CONFIG_DIR_NAME,
16
+ type ExtensionAPI,
17
+ type ExtensionCommandContext,
18
+ type ExtensionContext,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { join } from "node:path";
21
+ import { writeConfigValue } from "../../lib/config.ts";
22
+ import { sanitizeLine } from "../../lib/format.ts";
15
23
  import { supportsCustomUI } from "../../lib/interaction.ts";
16
- import type { Bridge } from "./bridge.ts";
24
+ import { isAllowedPeer, type Bridge } from "./bridge.ts";
17
25
  import type { WechatClient } from "./client.ts";
18
26
  import type { WechatConfig } from "./config.ts";
27
+ import { doctorReport } from "./doctor.ts";
19
28
  import { peerLabel, peerKey } from "./format.ts";
20
29
  import { waitForLogin, type LoginOutcome } from "./login.ts";
21
30
  import { defaultSleep } from "./timer.ts";
@@ -31,6 +40,8 @@ export interface CommandDeps {
31
40
  createLoginClient: (cwd: string) => WechatClient;
32
41
  saveAccount: (account: BotCredentials) => void;
33
42
  primaryAccount: () => BotCredentials | undefined;
43
+ /** Read a peer's recent messages for `/wechat history`; read-only. */
44
+ readHistory: (peer: string, count: number) => Promise<string>;
34
45
  logout: () => void;
35
46
  now: () => number;
36
47
  }
@@ -50,8 +61,13 @@ export const WECHAT_COMMANDS = [
50
61
  { verb: "login", description: "Log in by QR; on a scan, store the account and start the bridge" },
51
62
  { verb: "new", description: "Start a fresh host session (peer conversations are independent)" },
52
63
  { verb: "status", description: "Open the control screen (a one-line summary outside the TUI)" },
64
+ { verb: "pause", description: "Stop polling new messages; running turns may finish" },
65
+ { verb: "resume", description: "Resume polling after /wechat pause" },
66
+ { verb: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
53
67
  { verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
54
68
  { verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
69
+ { verb: "history", description: "Print one peer's recent messages: /wechat history <peer> [n]" },
70
+ { verb: "allow", description: "Allow a refused sender in the project config: /wechat allow [peer]" },
55
71
  { verb: "logout", description: "Clear the stored account and stop the bridge" },
56
72
  { verb: "help", description: "List these commands" },
57
73
  ] as const satisfies readonly WechatCommand[];
@@ -98,6 +114,102 @@ function busyWithPeerTurn(ctx: ExtensionContext, deps: CommandDeps, verb: string
98
114
  return true;
99
115
  }
100
116
 
117
+ /** Print the read-only health report, in the TUI, over RPC, or in a script. */
118
+ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void> {
119
+ ctx.ui.notify(
120
+ doctorReport({
121
+ status: deps.getBridge()?.status(),
122
+ account: deps.primaryAccount(),
123
+ config: deps.loadConfig(ctx.cwd),
124
+ now: deps.now(),
125
+ }),
126
+ );
127
+ }
128
+
129
+ /** Pause or resume polling; the account and peer sessions stay in place. */
130
+ async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume: boolean): Promise<void> {
131
+ const bridge = deps.getBridge();
132
+ if (!bridge) {
133
+ ctx.ui.notify("WeChat is not running.", "warning");
134
+ return;
135
+ }
136
+ if (resume) {
137
+ if (!bridge.status().paused) {
138
+ ctx.ui.notify("WeChat is not paused.");
139
+ return;
140
+ }
141
+ const ok = await bridge.resume();
142
+ ctx.ui.notify(
143
+ ok ? "WeChat polling resumed." : "WeChat could not resume; check the terminal notice.",
144
+ ok ? "info" : "warning",
145
+ );
146
+ return;
147
+ }
148
+ const ok = await bridge.pause();
149
+ ctx.ui.notify(
150
+ ok
151
+ ? "WeChat polling paused. Running turns may finish; no new messages will arrive."
152
+ : bridge.status().paused
153
+ ? "WeChat is already paused."
154
+ : "WeChat is not running.",
155
+ ok ? "info" : "warning",
156
+ );
157
+ }
158
+
159
+ /** Add a refused sender to the project config after the operator confirms. */
160
+ async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
161
+ if (!ctx.hasUI) {
162
+ ctx.ui.notify("WeChat allow needs a confirmation dialog, which this mode has no UI for.", "warning");
163
+ return;
164
+ }
165
+ const candidate = peerArg?.trim() || (await chooseRefused(ctx, deps));
166
+ if (!candidate) return;
167
+ const config = deps.loadConfig(ctx.cwd);
168
+ if (isAllowedPeer(candidate, undefined, config.allowedPeers)) {
169
+ ctx.ui.notify(`${sanitizeLine(peerLabel(candidate))} is already allowed.`);
170
+ return;
171
+ }
172
+ const relative = join(CONFIG_DIR_NAME, "bytefly.json");
173
+ const label = sanitizeLine(peerLabel(candidate));
174
+ const confirmed = await ctx.ui.confirm(`Allow ${label}?`, `Add this sender to wechat.allowedPeers in ${relative}?`);
175
+ if (!confirmed) return;
176
+ try {
177
+ writeConfigValue(join(ctx.cwd, relative), "wechat", "allowedPeers", [...config.allowedPeers, candidate]);
178
+ ctx.ui.notify(`Saved ${label} to ${relative}. Run /reload or restart Pi to apply.`);
179
+ } catch (error) {
180
+ ctx.ui.notify(`Could not save the allowlist: ${messageOf(error)}`, "error");
181
+ }
182
+ }
183
+
184
+ /** Pick one of the refused senders, or explain that there is nothing to allow. */
185
+ async function chooseRefused(ctx: ExtensionContext, deps: CommandDeps): Promise<string | undefined> {
186
+ const refused = deps.getBridge()?.status().refusedPeers ?? [];
187
+ if (refused.length === 0) {
188
+ ctx.ui.notify("No refused sender to allow.", "warning");
189
+ return undefined;
190
+ }
191
+ return ctx.ui.select("Allow which sender?", refused);
192
+ }
193
+
194
+ /** Print one peer's recent messages; read-only, and works without a live session. */
195
+ async function runHistory(ctx: ExtensionCommandContext, deps: CommandDeps, args: string): Promise<void> {
196
+ const [peer = "", countArg] = args.trim().split(/\s+/).filter(Boolean);
197
+ const bridge = deps.getBridge();
198
+ if (!peer) {
199
+ const known = bridge?.status().peerStates.map((entry) => peerLabel(entry.peer)) ?? [];
200
+ ctx.ui.notify(`Usage: /wechat history <peer> [n]. Known: ${known.join(", ") || "none yet"}.`, "warning");
201
+ return;
202
+ }
203
+ const requested = Number.parseInt(countArg ?? "", 10);
204
+ const count = Math.min(20, Math.max(1, Number.isFinite(requested) ? requested : 10));
205
+ try {
206
+ const text = await deps.readHistory(peer, count);
207
+ ctx.ui.notify(text || `No history yet for ${peerLabel(peer)}.`);
208
+ } catch (error) {
209
+ ctx.ui.notify(`WeChat history failed: ${messageOf(error)}`, "warning");
210
+ }
211
+ }
212
+
101
213
  /**
102
214
  * React to a finished login. A confirmed scan stores the account and starts the
103
215
  * bridge immediately, so `/wechat login` is the only command needed to go
@@ -214,9 +326,45 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
214
326
  theme,
215
327
  view,
216
328
  onClose: done,
329
+ actions: {
330
+ stop: (peer) => {
331
+ const bridge = deps.getBridge();
332
+ if (!bridge) return "WeChat bridge is not running.";
333
+ return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
334
+ },
335
+ reset: (peer) => {
336
+ const bridge = deps.getBridge();
337
+ if (!bridge) return "WeChat bridge is not running.";
338
+ return bridge.reset(peer)
339
+ ? `Started a fresh conversation for ${peerLabel(peer)}.`
340
+ : `${peerLabel(peer)} is running; stop it before starting fresh.`;
341
+ },
342
+ cancelPrompt: () => {
343
+ const bridge = deps.getBridge();
344
+ if (!bridge?.status().promptPending) return "No prompt is waiting.";
345
+ bridge.channel().cancel();
346
+ return "Cancelled the waiting prompt.";
347
+ },
348
+ togglePause: async () => {
349
+ const bridge = deps.getBridge();
350
+ if (!bridge) return "WeChat bridge is not running.";
351
+ if (bridge.status().paused) {
352
+ return (await bridge.resume()) ? "Resumed polling." : "Could not resume; see the notice.";
353
+ }
354
+ return (await bridge.pause()) ? "Paused polling. Running turns may finish." : "WeChat is not running.";
355
+ },
356
+ },
217
357
  requestRender: () => tui.requestRender(),
218
358
  }),
219
359
  );
360
+ if (typeof action === "object" && action?.kind === "history") {
361
+ await runHistory(ctx, deps, action.peer);
362
+ return;
363
+ }
364
+ if (typeof action === "object" && action?.kind === "allow") {
365
+ await runAllow(ctx, deps);
366
+ return;
367
+ }
220
368
  switch (action) {
221
369
  case "login":
222
370
  case "new":
@@ -296,6 +444,16 @@ async function dispatch(
296
444
  return runNew(ctx, deps);
297
445
  case "status":
298
446
  return runPanel(ctx, deps);
447
+ case "doctor":
448
+ return runDoctor(ctx, deps);
449
+ case "history":
450
+ return runHistory(ctx, deps, peerArg);
451
+ case "allow":
452
+ return runAllow(ctx, deps, peerArg || undefined);
453
+ case "pause":
454
+ return runPauseToggle(ctx, deps, false);
455
+ case "resume":
456
+ return runPauseToggle(ctx, deps, true);
299
457
  case "stop":
300
458
  return runPeerAction(ctx, deps, "stop", peerArg);
301
459
  case "reset":
@@ -311,7 +469,7 @@ async function dispatch(
311
469
  export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
312
470
  pi.registerCommand("wechat", {
313
471
  description:
314
- "WeChat bridge. /wechat opens the control screen (login | new | status | stop | reset | logout | help)",
472
+ "WeChat bridge. /wechat opens the control screen (login | new | status | pause | resume | doctor | stop | reset | history | allow | logout | help)",
315
473
  getArgumentCompletions: completeSubcommand,
316
474
  handler: async (args, ctx) => {
317
475
  const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -49,6 +49,10 @@ export interface DeliveryDeps {
49
49
  onExpired: () => void;
50
50
  /** Drop a peer's cached reply token. */
51
51
  forgetToken: (peer: string) => void;
52
+ /** The last send failure, for the doctor/status. */
53
+ onSendError?: (message: string) => void;
54
+ /** A message got through; clear the last failure. */
55
+ onSent?: () => void;
52
56
  }
53
57
 
54
58
  /** The bridge's outbound surface. */
@@ -137,9 +141,13 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
137
141
  if (failureNotified.has(target.peer)) return;
138
142
  failureNotified.add(target.peer);
139
143
  const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
144
+ deps.onSendError?.(messageOf(error));
140
145
  deps.notify(`WeChat ${label} failed${partial}: ${messageOf(error)}`, "warning");
141
146
  });
142
- if (!failed && delivery.ok) failureNotified.delete(target.peer);
147
+ if (!failed && delivery.ok) {
148
+ failureNotified.delete(target.peer);
149
+ deps.onSent?.();
150
+ }
143
151
  return delivery.ok;
144
152
  }
145
153
 
@@ -169,7 +177,11 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
169
177
  });
170
178
  // `sendLocalFile` reports only success, and the caller may fall back to text;
171
179
  // without this line the operator would never know the attachment was dropped.
172
- if (!sent && !signal?.aborted) deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
180
+ if (!sent && !signal?.aborted) {
181
+ deps.onSendError?.(`file delivery failed: ${fileName}`);
182
+ deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
183
+ }
184
+ if (sent) deps.onSent?.();
173
185
  return sent;
174
186
  },
175
187
 
@@ -178,11 +190,13 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
178
190
  const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
179
191
  deps.forgetToken(owner);
180
192
  const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
193
+ deps.onSendError?.(messageOf(error));
181
194
  failure = `${messageOf(error)}${partial}`;
182
195
  });
183
196
  if (delivery.expired) return { ok: false, error: "WeChat session expired." };
184
197
  if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
185
198
  if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
199
+ deps.onSent?.();
186
200
  return { ok: true };
187
201
  },
188
202