@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.
- package/extensions/wechat/README.md +33 -9
- package/extensions/wechat/bridge.ts +143 -23
- package/extensions/wechat/commands.ts +162 -4
- package/extensions/wechat/deliver.ts +16 -2
- package/extensions/wechat/doctor.ts +122 -0
- package/extensions/wechat/index.ts +7 -2
- package/extensions/wechat/interaction.ts +38 -1
- package/extensions/wechat/lock.ts +11 -2
- package/extensions/wechat/peer-worker.ts +10 -1
- package/extensions/wechat/poll.ts +10 -0
- package/extensions/wechat/status-screen.ts +224 -33
- package/package.json +1 -1
|
@@ -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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 {
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|