@gavin-hu/my-pi-agent 0.11.2 → 0.11.3
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 +21 -7
- package/extensions/wechat/bridge.ts +53 -1
- package/extensions/wechat/commands.ts +118 -4
- package/extensions/wechat/deliver.ts +16 -2
- package/extensions/wechat/doctor.ts +121 -0
- package/extensions/wechat/index.ts +4 -1
- 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 +192 -31
- 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,11 @@ 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 doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
|
|
77
80
|
| `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
|
|
78
81
|
| `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
|
|
82
|
+
| `/wechat history <peer> [n]` | Print that peer's recent messages (default 10, max 20); read-only, and works without a live session. |
|
|
83
|
+
| `/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
84
|
| `/wechat logout` | Confirm, then clear credentials, drop the bridge, and stop polling. |
|
|
80
85
|
| `/wechat help` | List the subcommands with a line each. |
|
|
81
86
|
|
|
@@ -98,10 +103,15 @@ one-step recovery.
|
|
|
98
103
|
|
|
99
104
|
In the TUI, `/wechat` opens a control screen with the account, connection
|
|
100
105
|
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
|
-
|
|
106
|
+
what is queued behind it), activity counters, and the waiting remote prompt
|
|
107
|
+
(peer, question, and age).
|
|
108
|
+
`j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
|
|
109
|
+
peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
|
|
110
|
+
the screen and prints that peer's history; `a` closes it and offers the refused
|
|
111
|
+
senders to allow; `c` withdraws the waiting prompt. `l` logs in or reconnects,
|
|
112
|
+
`n` starts a new session, `o` logs out, `r` refreshes, and Esc closes. The
|
|
113
|
+
subcommands above are shortcuts for the same actions and the only route outside
|
|
114
|
+
the TUI; an unknown subcommand reports the valid ones.
|
|
105
115
|
|
|
106
116
|
A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
|
|
107
117
|
command typed at the terminal declines while one is running: `status` prints the
|
|
@@ -304,6 +314,9 @@ never touches the stored cursor.
|
|
|
304
314
|
|
|
305
315
|
## Troubleshooting
|
|
306
316
|
|
|
317
|
+
- **First, run `/wechat doctor`.** It prints the connection, token age, last
|
|
318
|
+
poll, poller lock, per-peer queue/session age, and the last failure, which
|
|
319
|
+
answers most "why is the bot quiet?" questions in one screen.
|
|
307
320
|
- **`Another Pi session is already polling this WeChat account.`** — one runtime
|
|
308
321
|
owns the account at a time, tracked by `<agent-dir>/wechat/poller.lock`.
|
|
309
322
|
Quit the other `pi` process. A lock left by a process that already exited is
|
|
@@ -382,7 +395,7 @@ sessions are in-process).
|
|
|
382
395
|
|
|
383
396
|
| Integration point | Detail |
|
|
384
397
|
|---|---|
|
|
385
|
-
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `stop <peer>`, `reset <peer>`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
398
|
+
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
386
399
|
| Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
|
|
387
400
|
| 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
401
|
| 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. |
|
|
@@ -483,7 +496,7 @@ status, which is what the old start/stop guidance used to do.
|
|
|
483
496
|
| `login.ts` | QR login state machine. |
|
|
484
497
|
| `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
|
|
485
498
|
| `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`). |
|
|
499
|
+
| `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
500
|
| `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
|
|
488
501
|
| `credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
|
|
489
502
|
| `state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
|
|
@@ -497,6 +510,7 @@ status, which is what the old start/stop guidance used to do.
|
|
|
497
510
|
| `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
511
|
| `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
512
|
| `history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
|
|
513
|
+
| `doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
|
|
500
514
|
| `types.ts` | Wire and domain types. |
|
|
501
515
|
|
|
502
516
|
## 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";
|
|
@@ -95,6 +99,8 @@ 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 {
|
|
@@ -114,6 +120,22 @@ export interface BridgeStatus {
|
|
|
114
120
|
mediaDropped: number;
|
|
115
121
|
/** Whether a remote dialog prompt is waiting for an answer. */
|
|
116
122
|
promptPending: boolean;
|
|
123
|
+
/** The waiting prompt's peer, kind, title, and age. */
|
|
124
|
+
prompt?: PendingPromptInfo;
|
|
125
|
+
/** When this connection opened. */
|
|
126
|
+
startedAt?: number;
|
|
127
|
+
/** When the last poll settled, successful or not. */
|
|
128
|
+
lastPollAt?: number;
|
|
129
|
+
/** The last poll failure, cleared by a successful poll. */
|
|
130
|
+
lastPollError?: string;
|
|
131
|
+
/** The last outbound send failure, cleared by a successful send. */
|
|
132
|
+
lastSendError?: string;
|
|
133
|
+
/** The long-poll hold currently in use (server hint or configured). */
|
|
134
|
+
pollHoldMs?: number;
|
|
135
|
+
/** The stored account's login time. */
|
|
136
|
+
tokenCreatedAt?: number;
|
|
137
|
+
/** The poller lock on disk, for the doctor. */
|
|
138
|
+
lock?: { pid: number; at: number };
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
export interface SendResult {
|
|
@@ -144,6 +166,11 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
144
166
|
const armTimer = deps.armTimer ?? defaultTimer;
|
|
145
167
|
let running = false;
|
|
146
168
|
let expired = false;
|
|
169
|
+
let startedAt: number | undefined;
|
|
170
|
+
let lastPollAt: number | undefined;
|
|
171
|
+
let lastPollError: string | undefined;
|
|
172
|
+
let lastSendError: string | undefined;
|
|
173
|
+
let pollHoldMs: number | undefined;
|
|
147
174
|
let abort: AbortController | undefined;
|
|
148
175
|
let loopPromise: Promise<void> | undefined;
|
|
149
176
|
let client: WechatClient | undefined;
|
|
@@ -204,6 +231,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
204
231
|
notify,
|
|
205
232
|
onExpired: handleExpired,
|
|
206
233
|
forgetToken,
|
|
234
|
+
onSendError: (message) => {
|
|
235
|
+
lastSendError = message;
|
|
236
|
+
},
|
|
237
|
+
onSent: () => {
|
|
238
|
+
lastSendError = undefined;
|
|
239
|
+
},
|
|
207
240
|
});
|
|
208
241
|
|
|
209
242
|
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
@@ -420,6 +453,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
420
453
|
refreshLock: deps.refreshLock,
|
|
421
454
|
isRunning: () => running,
|
|
422
455
|
pollTimeout,
|
|
456
|
+
now: deps.now,
|
|
457
|
+
onPoll: (result) => {
|
|
458
|
+
lastPollAt = result.at;
|
|
459
|
+
lastPollError = result.error;
|
|
460
|
+
if (result.holdMs !== undefined) pollHoldMs = result.holdMs;
|
|
461
|
+
},
|
|
423
462
|
});
|
|
424
463
|
|
|
425
464
|
/**
|
|
@@ -438,6 +477,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
438
477
|
budget.reset();
|
|
439
478
|
admission.reset();
|
|
440
479
|
inbound.reset();
|
|
480
|
+
lastPollAt = undefined;
|
|
481
|
+
lastPollError = undefined;
|
|
482
|
+
lastSendError = undefined;
|
|
483
|
+
pollHoldMs = undefined;
|
|
441
484
|
}
|
|
442
485
|
|
|
443
486
|
function releaseSafe(): void {
|
|
@@ -482,6 +525,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
482
525
|
running = true;
|
|
483
526
|
expired = false;
|
|
484
527
|
resetEphemeral();
|
|
528
|
+
startedAt = deps.now();
|
|
485
529
|
// One worker per connection: it owns the per-peer queues and sessions, so
|
|
486
530
|
// a login (or a reconnect after an expiry) starts from a clean slate while
|
|
487
531
|
// each peer's history stays on disk.
|
|
@@ -573,6 +617,14 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
573
617
|
refusedPeers: [...refusedPeers],
|
|
574
618
|
mediaDropped: inbound.dropped(),
|
|
575
619
|
promptPending: interaction.hasPending(),
|
|
620
|
+
prompt: interaction.pendingInfo(),
|
|
621
|
+
startedAt,
|
|
622
|
+
lastPollAt,
|
|
623
|
+
lastPollError,
|
|
624
|
+
lastSendError,
|
|
625
|
+
pollHoldMs: pollHoldMs ?? deps.config.pollHoldMs,
|
|
626
|
+
tokenCreatedAt: account?.createdAt,
|
|
627
|
+
lock: deps.loadLock?.(),
|
|
576
628
|
};
|
|
577
629
|
},
|
|
578
630
|
|
|
@@ -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,11 @@ 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: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
|
|
53
65
|
{ verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
|
|
54
66
|
{ verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
|
|
67
|
+
{ verb: "history", description: "Print one peer's recent messages: /wechat history <peer> [n]" },
|
|
68
|
+
{ verb: "allow", description: "Allow a refused sender in the project config: /wechat allow [peer]" },
|
|
55
69
|
{ verb: "logout", description: "Clear the stored account and stop the bridge" },
|
|
56
70
|
{ verb: "help", description: "List these commands" },
|
|
57
71
|
] as const satisfies readonly WechatCommand[];
|
|
@@ -98,6 +112,72 @@ function busyWithPeerTurn(ctx: ExtensionContext, deps: CommandDeps, verb: string
|
|
|
98
112
|
return true;
|
|
99
113
|
}
|
|
100
114
|
|
|
115
|
+
/** Print the read-only health report, in the TUI, over RPC, or in a script. */
|
|
116
|
+
async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void> {
|
|
117
|
+
ctx.ui.notify(
|
|
118
|
+
doctorReport({
|
|
119
|
+
status: deps.getBridge()?.status(),
|
|
120
|
+
account: deps.primaryAccount(),
|
|
121
|
+
config: deps.loadConfig(ctx.cwd),
|
|
122
|
+
now: deps.now(),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Add a refused sender to the project config after the operator confirms. */
|
|
128
|
+
async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
|
|
129
|
+
if (!ctx.hasUI) {
|
|
130
|
+
ctx.ui.notify("WeChat allow needs a confirmation dialog, which this mode has no UI for.", "warning");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const candidate = peerArg?.trim() || (await chooseRefused(ctx, deps));
|
|
134
|
+
if (!candidate) return;
|
|
135
|
+
const config = deps.loadConfig(ctx.cwd);
|
|
136
|
+
if (isAllowedPeer(candidate, undefined, config.allowedPeers)) {
|
|
137
|
+
ctx.ui.notify(`${sanitizeLine(peerLabel(candidate))} is already allowed.`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const relative = join(CONFIG_DIR_NAME, "bytefly.json");
|
|
141
|
+
const label = sanitizeLine(peerLabel(candidate));
|
|
142
|
+
const confirmed = await ctx.ui.confirm(`Allow ${label}?`, `Add this sender to wechat.allowedPeers in ${relative}?`);
|
|
143
|
+
if (!confirmed) return;
|
|
144
|
+
try {
|
|
145
|
+
writeConfigValue(join(ctx.cwd, relative), "wechat", "allowedPeers", [...config.allowedPeers, candidate]);
|
|
146
|
+
ctx.ui.notify(`Saved ${label} to ${relative}. Run /reload or restart Pi to apply.`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
ctx.ui.notify(`Could not save the allowlist: ${messageOf(error)}`, "error");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Pick one of the refused senders, or explain that there is nothing to allow. */
|
|
153
|
+
async function chooseRefused(ctx: ExtensionContext, deps: CommandDeps): Promise<string | undefined> {
|
|
154
|
+
const refused = deps.getBridge()?.status().refusedPeers ?? [];
|
|
155
|
+
if (refused.length === 0) {
|
|
156
|
+
ctx.ui.notify("No refused sender to allow.", "warning");
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return ctx.ui.select("Allow which sender?", refused);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Print one peer's recent messages; read-only, and works without a live session. */
|
|
163
|
+
async function runHistory(ctx: ExtensionCommandContext, deps: CommandDeps, args: string): Promise<void> {
|
|
164
|
+
const [peer = "", countArg] = args.trim().split(/\s+/).filter(Boolean);
|
|
165
|
+
const bridge = deps.getBridge();
|
|
166
|
+
if (!peer) {
|
|
167
|
+
const known = bridge?.status().peerStates.map((entry) => peerLabel(entry.peer)) ?? [];
|
|
168
|
+
ctx.ui.notify(`Usage: /wechat history <peer> [n]. Known: ${known.join(", ") || "none yet"}.`, "warning");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const requested = Number.parseInt(countArg ?? "", 10);
|
|
172
|
+
const count = Math.min(20, Math.max(1, Number.isFinite(requested) ? requested : 10));
|
|
173
|
+
try {
|
|
174
|
+
const text = await deps.readHistory(peer, count);
|
|
175
|
+
ctx.ui.notify(text || `No history yet for ${peerLabel(peer)}.`);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
ctx.ui.notify(`WeChat history failed: ${messageOf(error)}`, "warning");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
101
181
|
/**
|
|
102
182
|
* React to a finished login. A confirmed scan stores the account and starts the
|
|
103
183
|
* bridge immediately, so `/wechat login` is the only command needed to go
|
|
@@ -214,9 +294,37 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
|
|
|
214
294
|
theme,
|
|
215
295
|
view,
|
|
216
296
|
onClose: done,
|
|
297
|
+
actions: {
|
|
298
|
+
stop: (peer) => {
|
|
299
|
+
const bridge = deps.getBridge();
|
|
300
|
+
if (!bridge) return "WeChat bridge is not running.";
|
|
301
|
+
return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
|
|
302
|
+
},
|
|
303
|
+
reset: (peer) => {
|
|
304
|
+
const bridge = deps.getBridge();
|
|
305
|
+
if (!bridge) return "WeChat bridge is not running.";
|
|
306
|
+
return bridge.reset(peer)
|
|
307
|
+
? `Started a fresh conversation for ${peerLabel(peer)}.`
|
|
308
|
+
: `${peerLabel(peer)} is running; stop it before starting fresh.`;
|
|
309
|
+
},
|
|
310
|
+
cancelPrompt: () => {
|
|
311
|
+
const bridge = deps.getBridge();
|
|
312
|
+
if (!bridge?.status().promptPending) return "No prompt is waiting.";
|
|
313
|
+
bridge.channel().cancel();
|
|
314
|
+
return "Cancelled the waiting prompt.";
|
|
315
|
+
},
|
|
316
|
+
},
|
|
217
317
|
requestRender: () => tui.requestRender(),
|
|
218
318
|
}),
|
|
219
319
|
);
|
|
320
|
+
if (typeof action === "object" && action?.kind === "history") {
|
|
321
|
+
await runHistory(ctx, deps, action.peer);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (typeof action === "object" && action?.kind === "allow") {
|
|
325
|
+
await runAllow(ctx, deps);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
220
328
|
switch (action) {
|
|
221
329
|
case "login":
|
|
222
330
|
case "new":
|
|
@@ -296,6 +404,12 @@ async function dispatch(
|
|
|
296
404
|
return runNew(ctx, deps);
|
|
297
405
|
case "status":
|
|
298
406
|
return runPanel(ctx, deps);
|
|
407
|
+
case "doctor":
|
|
408
|
+
return runDoctor(ctx, deps);
|
|
409
|
+
case "history":
|
|
410
|
+
return runHistory(ctx, deps, peerArg);
|
|
411
|
+
case "allow":
|
|
412
|
+
return runAllow(ctx, deps, peerArg || undefined);
|
|
299
413
|
case "stop":
|
|
300
414
|
return runPeerAction(ctx, deps, "stop", peerArg);
|
|
301
415
|
case "reset":
|
|
@@ -311,7 +425,7 @@ async function dispatch(
|
|
|
311
425
|
export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
312
426
|
pi.registerCommand("wechat", {
|
|
313
427
|
description:
|
|
314
|
-
"WeChat bridge. /wechat opens the control screen (login | new | status | stop | reset | logout | help)",
|
|
428
|
+
"WeChat bridge. /wechat opens the control screen (login | new | status | doctor | stop | reset | history | allow | logout | help)",
|
|
315
429
|
getArgumentCompletions: completeSubcommand,
|
|
316
430
|
handler: async (args, ctx) => {
|
|
317
431
|
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
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-text health report behind `/wechat doctor`.
|
|
3
|
+
*
|
|
4
|
+
* It answers the operator's first question — why is the bot quiet or slow? — from
|
|
5
|
+
* facts the bridge already has: the connection, the token's age, the last poll,
|
|
6
|
+
* the poller lock, per-peer sessions, and the last send/poll failure. Pure, so
|
|
7
|
+
* the wording and the age math are unit-tested without a bridge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { formatDuration } from "../../lib/format.ts";
|
|
11
|
+
import type { BridgeStatus } from "./bridge.ts";
|
|
12
|
+
import type { WechatConfig } from "./config.ts";
|
|
13
|
+
import type { BotCredentials } from "./types.ts";
|
|
14
|
+
import { peerLabel } from "./format.ts";
|
|
15
|
+
|
|
16
|
+
export interface DoctorInput {
|
|
17
|
+
/** Undefined when the bridge has never been built in this runtime. */
|
|
18
|
+
status?: BridgeStatus;
|
|
19
|
+
account?: BotCredentials;
|
|
20
|
+
config: WechatConfig;
|
|
21
|
+
now: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LABEL = 11;
|
|
25
|
+
|
|
26
|
+
/** One `Label value` line. */
|
|
27
|
+
function row(label: string, value: string): string {
|
|
28
|
+
return `${label.padEnd(LABEL)}${value}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `3.2s` since `at`, or a placeholder. */
|
|
32
|
+
function age(now: number, at: number | undefined): string {
|
|
33
|
+
return at === undefined ? "unknown" : `${formatDuration(Math.max(0, now - at))} ago`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function connection(status: BridgeStatus | undefined, account: BotCredentials | undefined, now: number): string {
|
|
37
|
+
if (!status?.open) {
|
|
38
|
+
return status?.expired ? "expired · run /wechat login" : "closed";
|
|
39
|
+
}
|
|
40
|
+
const owner = status.owner ? peerLabel(status.owner) : "unclaimed";
|
|
41
|
+
const token = account ? `${formatDuration(Math.max(0, now - account.createdAt))} old` : "unknown age";
|
|
42
|
+
return `open · owner ${owner} · token ${token}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function poll(status: BridgeStatus | undefined, now: number): string {
|
|
46
|
+
if (!status) return "not started";
|
|
47
|
+
const last =
|
|
48
|
+
status.lastPollAt === undefined
|
|
49
|
+
? "no poll yet"
|
|
50
|
+
: `last ${formatDuration(Math.max(0, now - status.lastPollAt))} ago`;
|
|
51
|
+
const hold = status.pollHoldMs === undefined ? "server hold" : `hold ${formatDuration(status.pollHoldMs)}`;
|
|
52
|
+
const error = status.lastPollError ? ` · error: ${status.lastPollError}` : "";
|
|
53
|
+
return `${last} · ${hold}${error}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lockLine(status: BridgeStatus | undefined, now: number): string {
|
|
57
|
+
const lock = status?.lock;
|
|
58
|
+
if (!lock) return status?.open ? "not held" : "not running";
|
|
59
|
+
return `pid ${lock.pid} · ${age(now, lock.at)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function peers(status: BridgeStatus | undefined): string[] {
|
|
63
|
+
if (!status || status.peerStates.length === 0) return [row("Peers", "none")];
|
|
64
|
+
const running = status.peerStates.filter((entry) => entry.state.status === "running").length;
|
|
65
|
+
const lines = [row("Peers", `${status.peerStates.length} · ${running} running · ${status.queued} queued`)];
|
|
66
|
+
for (const entry of status.peerStates) {
|
|
67
|
+
const state = entry.state;
|
|
68
|
+
const what =
|
|
69
|
+
state.status === "running"
|
|
70
|
+
? `running ${formatDuration(state.runningMs ?? 0)}`
|
|
71
|
+
: state.status === "queued"
|
|
72
|
+
? `queued ${state.queued}`
|
|
73
|
+
: "idle";
|
|
74
|
+
const session = state.sessionAgeMs === undefined ? "" : ` · session ${formatDuration(state.sessionAgeMs)}`;
|
|
75
|
+
lines.push(row(` ${peerLabel(entry.peer)}`, `${what}${session}`));
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function recent(status: BridgeStatus | undefined): string {
|
|
81
|
+
const errors = [
|
|
82
|
+
status?.lastSendError ? `send: ${status.lastSendError}` : undefined,
|
|
83
|
+
status?.lastPollError ? `poll: ${status.lastPollError}` : undefined,
|
|
84
|
+
].filter((entry): entry is string => entry !== undefined);
|
|
85
|
+
return row("Recent", errors.length > 0 ? errors.join(" · ") : "no recent failure");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function media(status: BridgeStatus | undefined): string {
|
|
89
|
+
const dropped = status?.mediaDropped ?? 0;
|
|
90
|
+
const refused = status?.refused ?? 0;
|
|
91
|
+
const names = status?.refusedPeers.join(", ");
|
|
92
|
+
return row(
|
|
93
|
+
"Media",
|
|
94
|
+
`${dropped} dropped · ${refused} refused${names ? ` (${names})` : ""} · ${status?.promptPending ? "prompt waiting" : "no prompt"}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function configLine(config: WechatConfig): string {
|
|
99
|
+
const overrides = Object.keys(config.peers).length;
|
|
100
|
+
const limits = config.maxTurnsPerHour > 0 ? `${config.maxTurnsPerHour} turns/h` : "no turn cap";
|
|
101
|
+
return row(
|
|
102
|
+
"Config",
|
|
103
|
+
`typing ${config.typingIndicator ? "on" : "off"} · prompts ${config.remotePrompts ? "on" : "off"} · ` +
|
|
104
|
+
`${config.allowedPeers.length} allowed · ${overrides} peer override(s) · ${limits}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The doctor report, one line per fact. */
|
|
109
|
+
export function doctorReport(input: DoctorInput): string {
|
|
110
|
+
const { status, account, config, now } = input;
|
|
111
|
+
return [
|
|
112
|
+
"wechat — health",
|
|
113
|
+
row("Connection", connection(status, account, now)),
|
|
114
|
+
row("Poll", poll(status, now)),
|
|
115
|
+
row("Lock", lockLine(status, now)),
|
|
116
|
+
...peers(status),
|
|
117
|
+
recent(status),
|
|
118
|
+
media(status),
|
|
119
|
+
configLine(config),
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
@@ -23,7 +23,7 @@ import { registerCommands, type CommandDeps } from "./commands.ts";
|
|
|
23
23
|
import { messageOf } from "./errors.ts";
|
|
24
24
|
import { loadWechatConfig } from "./config.ts";
|
|
25
25
|
import { clearCredentials, primaryAccount, saveAccount, wechatDir } from "./credentials.ts";
|
|
26
|
-
import { acquireLock, lockPath, refreshLock, releaseLock } from "./lock.ts";
|
|
26
|
+
import { acquireLock, lockPath, readLock, refreshLock, releaseLock } from "./lock.ts";
|
|
27
27
|
import { openPeerSession, type PeerSession, type PeerSessionOptions } from "./peer-session.ts";
|
|
28
28
|
import { createPeerUIContext } from "./remote-ui.ts";
|
|
29
29
|
import { peerKey } from "./format.ts";
|
|
@@ -127,6 +127,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
127
127
|
acquireLock: () => acquireLock(lockPath(), lockDeps()),
|
|
128
128
|
refreshLock: () => refreshLock(lockPath(), lockDeps()),
|
|
129
129
|
releaseLock: () => releaseLock(lockPath(), process.pid),
|
|
130
|
+
loadLock: () => readLock(lockPath()),
|
|
130
131
|
notify: (message, kind) => {
|
|
131
132
|
ctxRef?.ui.notify(message, kind);
|
|
132
133
|
},
|
|
@@ -180,6 +181,8 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
180
181
|
},
|
|
181
182
|
saveAccount,
|
|
182
183
|
primaryAccount,
|
|
184
|
+
readHistory: (peer, count) =>
|
|
185
|
+
readPeerHistory(ctxRef?.cwd ?? process.cwd(), join(wechatDir(), "sessions"), peer, count),
|
|
183
186
|
logout: () => {
|
|
184
187
|
clearCredentials();
|
|
185
188
|
// The account is the on/off switch: without one there is nothing to report,
|
|
@@ -38,6 +38,8 @@ export interface WechatInteractionDeps {
|
|
|
38
38
|
language: () => PeerLanguage;
|
|
39
39
|
/** Arm a cancellable one-shot timer. Defaults to a real, unref'd `setTimeout`. */
|
|
40
40
|
armTimer?: ArmTimer;
|
|
41
|
+
/** Clock for the prompt's start time; defaults to `Date.now`. */
|
|
42
|
+
now?: () => number;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface WechatInteractionChannel extends InteractionChannel {
|
|
@@ -45,13 +47,27 @@ export interface WechatInteractionChannel extends InteractionChannel {
|
|
|
45
47
|
handleInbound(peer: string, text: string): boolean;
|
|
46
48
|
/** Whether a prompt is currently waiting for a reply. */
|
|
47
49
|
hasPending(): boolean;
|
|
50
|
+
/** The waiting prompt, for the control screen; undefined when none waits. */
|
|
51
|
+
pendingInfo(): PendingPromptInfo | undefined;
|
|
52
|
+
/** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
|
|
53
|
+
cancel(): void;
|
|
48
54
|
/** Drop any pending prompt (turn settled or the bridge stopped). */
|
|
49
55
|
reset(): void;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/** The waiting prompt, as the control screen reads it. */
|
|
59
|
+
export interface PendingPromptInfo {
|
|
60
|
+
peer: string;
|
|
61
|
+
kind: InteractionRequest["kind"];
|
|
62
|
+
title: string;
|
|
63
|
+
/** When the prompt was registered, in epoch milliseconds. */
|
|
64
|
+
startedAt: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
interface Pending {
|
|
53
68
|
request: InteractionRequest;
|
|
54
69
|
peer: string;
|
|
70
|
+
startedAt: number;
|
|
55
71
|
resolve: (answer: InteractionAnswer) => void;
|
|
56
72
|
signal?: AbortSignal;
|
|
57
73
|
onAbort: () => void;
|
|
@@ -61,6 +77,7 @@ interface Pending {
|
|
|
61
77
|
|
|
62
78
|
export function createWechatInteractionChannel(deps: WechatInteractionDeps): WechatInteractionChannel {
|
|
63
79
|
const arm = deps.armTimer ?? defaultTimer;
|
|
80
|
+
const now = deps.now ?? Date.now;
|
|
64
81
|
let pending: Pending | undefined;
|
|
65
82
|
/**
|
|
66
83
|
* Free text captured from a `select` "Other" reply, per peer: the immediately
|
|
@@ -88,6 +105,26 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
88
105
|
return pending !== undefined;
|
|
89
106
|
},
|
|
90
107
|
|
|
108
|
+
pendingInfo(): PendingPromptInfo | undefined {
|
|
109
|
+
const current = pending;
|
|
110
|
+
return current === undefined
|
|
111
|
+
? undefined
|
|
112
|
+
: {
|
|
113
|
+
peer: current.peer,
|
|
114
|
+
kind: current.request.kind,
|
|
115
|
+
title: current.request.title,
|
|
116
|
+
startedAt: current.startedAt,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
cancel(): void {
|
|
121
|
+
if (!pending) return;
|
|
122
|
+
// The peer saw the prompt; one line says it was withdrawn. `reset` is the
|
|
123
|
+
// silent cleanup path and deliberately does not send this.
|
|
124
|
+
void deps.send(notice("timeout", deps.language()));
|
|
125
|
+
settle({ kind: "cancelled" });
|
|
126
|
+
},
|
|
127
|
+
|
|
91
128
|
async post(text: string, signal?: AbortSignal): Promise<void> {
|
|
92
129
|
await deps.send(text, signal);
|
|
93
130
|
},
|
|
@@ -117,7 +154,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
117
154
|
const cancelTimers: Array<() => void> = [];
|
|
118
155
|
const answer = new Promise<InteractionAnswer>((resolve) => {
|
|
119
156
|
const onAbort = () => settle({ kind: "cancelled" });
|
|
120
|
-
pending = { request, peer, resolve, signal, onAbort, cancelTimers };
|
|
157
|
+
pending = { request, peer, startedAt: now(), resolve, signal, onAbort, cancelTimers };
|
|
121
158
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
122
159
|
// An ignored question must not freeze the session: nudge once, then
|
|
123
160
|
// cancel so the model gets a clean answer instead of a hang.
|
|
@@ -48,10 +48,19 @@ function parseLock(text: string): LockInfo | undefined {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** The raw lock record, or undefined when the file is missing or corrupt. */
|
|
52
|
+
export function readLock(path: string): LockInfo | undefined {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(path)) return undefined;
|
|
55
|
+
return parseLock(readFileSync(path, "utf-8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
/** True when the existing lock blocks a new owner. */
|
|
52
62
|
export function isLocked(path: string, deps: LockDeps): boolean {
|
|
53
|
-
|
|
54
|
-
const info = parseLock(readFileSync(path, "utf-8"));
|
|
63
|
+
const info = readLock(path);
|
|
55
64
|
if (!info) return false;
|
|
56
65
|
if (info.pid === deps.pid) return false;
|
|
57
66
|
const stale = deps.now() - info.at > (deps.staleMs ?? DEFAULT_STALE_MS);
|
|
@@ -75,6 +75,8 @@ export interface PeerState {
|
|
|
75
75
|
queued: number;
|
|
76
76
|
/** How long the running turn has been going, in milliseconds. */
|
|
77
77
|
runningMs?: number;
|
|
78
|
+
/** How long the peer's session has been open, when one is open. */
|
|
79
|
+
sessionAgeMs?: number;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export interface PeerSnapshot {
|
|
@@ -110,6 +112,8 @@ interface PeerEntry {
|
|
|
110
112
|
/** Monotonic start time of the running turn, for `runningMs`. */
|
|
111
113
|
startedAt?: number;
|
|
112
114
|
session?: PeerSession;
|
|
115
|
+
/** When the current session opened, for the doctor's session age. */
|
|
116
|
+
sessionSince?: number;
|
|
113
117
|
opening?: Promise<PeerSession>;
|
|
114
118
|
disposeTimer?: () => void;
|
|
115
119
|
}
|
|
@@ -164,15 +168,18 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
164
168
|
function stateOf(peer: string): PeerState {
|
|
165
169
|
const target = peers.get(peer);
|
|
166
170
|
if (!target) return { status: "idle", queued: 0 };
|
|
171
|
+
const sessionAge =
|
|
172
|
+
target.sessionSince === undefined ? undefined : { sessionAgeMs: Math.max(0, now() - target.sessionSince) };
|
|
167
173
|
if (target.running) {
|
|
168
174
|
return {
|
|
169
175
|
status: "running",
|
|
170
176
|
queued: target.queue.length + target.merged,
|
|
171
177
|
runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
|
|
178
|
+
...sessionAge,
|
|
172
179
|
};
|
|
173
180
|
}
|
|
174
181
|
const queued = target.queue.length + target.merged;
|
|
175
|
-
return { status: queued > 0 ? "queued" : "idle", queued };
|
|
182
|
+
return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
/** Drop the peer's session and queue. */
|
|
@@ -182,6 +189,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
182
189
|
cancelIdleSweep(target);
|
|
183
190
|
target.session?.dispose();
|
|
184
191
|
target.session = undefined;
|
|
192
|
+
target.sessionSince = undefined;
|
|
185
193
|
target.opening = undefined;
|
|
186
194
|
}
|
|
187
195
|
|
|
@@ -212,6 +220,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
212
220
|
throw new Error("The WeChat bridge closed while opening the session.");
|
|
213
221
|
}
|
|
214
222
|
target.session = opened;
|
|
223
|
+
target.sessionSince = now();
|
|
215
224
|
return opened;
|
|
216
225
|
})
|
|
217
226
|
.finally(() => {
|
|
@@ -45,6 +45,9 @@ export interface PollDeps {
|
|
|
45
45
|
onMessages: (messages: WeixinMessage[]) => void;
|
|
46
46
|
refreshLock: () => void;
|
|
47
47
|
isRunning: () => boolean;
|
|
48
|
+
now: () => number;
|
|
49
|
+
/** Report the last poll attempt, for the doctor/status. */
|
|
50
|
+
onPoll?: (result: { at: number; error?: string; holdMs?: number }) => void;
|
|
48
51
|
pollTimeout: (serverHintMs: number | undefined) => number;
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -82,6 +85,10 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
82
85
|
} catch (error) {
|
|
83
86
|
if (signal.aborted || !deps.isRunning()) break;
|
|
84
87
|
if (watchdog.signal.aborted) {
|
|
88
|
+
deps.onPoll?.({
|
|
89
|
+
at: deps.now(),
|
|
90
|
+
error: `timed out after ${Math.round(watchdogMs / 1000)}s`,
|
|
91
|
+
});
|
|
85
92
|
// A wedged request, not a server answer: retry at once with the same
|
|
86
93
|
// cursor, so nothing is skipped, and say so once.
|
|
87
94
|
if (!timeoutNotified) {
|
|
@@ -90,6 +97,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
90
97
|
}
|
|
91
98
|
continue;
|
|
92
99
|
}
|
|
100
|
+
deps.onPoll?.({ at: deps.now(), error: messageOf(error) });
|
|
93
101
|
deps.notify(`WeChat poll failed: ${messageOf(error)}`, "warning");
|
|
94
102
|
try {
|
|
95
103
|
await deps.sleep(BACKOFF_MS, signal);
|
|
@@ -112,6 +120,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
112
120
|
if (!rejectedNotified) {
|
|
113
121
|
rejectedNotified = true;
|
|
114
122
|
const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
|
|
123
|
+
deps.onPoll?.({ at: deps.now(), error: `rejected ret ${code}` });
|
|
115
124
|
deps.notify(`WeChat poll rejected (ret ${code}); retrying.`, "warning");
|
|
116
125
|
}
|
|
117
126
|
try {
|
|
@@ -130,6 +139,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
130
139
|
}
|
|
131
140
|
deps.onMessages(response.msgs);
|
|
132
141
|
deps.refreshLock();
|
|
142
|
+
deps.onPoll?.({ at: deps.now(), holdMs: serverHint ?? deps.holdMs() });
|
|
133
143
|
}
|
|
134
144
|
},
|
|
135
145
|
};
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `connectionOf`, `statusView`, `statusLine`, and `statusBody` are pure, so the
|
|
5
5
|
* wording, fields, and width safety are unit-tested; `StatusScreenComponent` is
|
|
6
|
-
* the TUI mounted by `runPanel`. It is the control hub:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* the TUI mounted by `runPanel`. It is the control hub: the global keys (login,
|
|
7
|
+
* new session, logout) close the screen and dispatch, while a focused peer row
|
|
8
|
+
* can be stopped, reset, or opened in `/wechat history` without leaving the
|
|
9
|
+
* screen.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { Component } from "@earendil-works/pi-tui";
|
|
14
|
+
import { formatDuration, sanitizeLine } from "../../lib/format.ts";
|
|
14
15
|
import { screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
|
|
15
16
|
import { clip } from "../../lib/tui/columns.ts";
|
|
16
|
-
import { isCloseKey } from "../../lib/tui/cursor.ts";
|
|
17
|
+
import { formatRange, IdCursor, isCloseKey, navIntent } from "../../lib/tui/cursor.ts";
|
|
17
18
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
18
|
-
import { keyValueRow } from "../../lib/tui/rows.ts";
|
|
19
|
+
import { keyValueRow, listRow } from "../../lib/tui/rows.ts";
|
|
19
20
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
20
21
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
22
|
import type { BridgeStatus } from "./bridge.ts";
|
|
@@ -25,8 +26,11 @@ import type { BotCredentials } from "./types.ts";
|
|
|
25
26
|
/** User-facing connection word; the internal `BridgeState` stays running/stopped. */
|
|
26
27
|
export type StatusConnection = "open" | "expired" | "closed";
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* What the status screen closed with: a global action, a peer history request,
|
|
31
|
+
* or `close`.
|
|
32
|
+
*/
|
|
33
|
+
export type StatusAction = "close" | "login" | "new" | "logout" | { kind: "history"; peer: string } | { kind: "allow" };
|
|
30
34
|
|
|
31
35
|
/** The facts the status screen and the one-line summary render. */
|
|
32
36
|
export interface StatusView {
|
|
@@ -44,17 +48,23 @@ export interface StatusView {
|
|
|
44
48
|
refusedPeers: string[];
|
|
45
49
|
mediaDropped: number;
|
|
46
50
|
promptPending: boolean;
|
|
51
|
+
/** The waiting prompt's peer, title, and start time. */
|
|
52
|
+
prompt?: { peer: string; title: string; startedAt: number };
|
|
53
|
+
/** The clock the view was built with, for the prompt age. */
|
|
54
|
+
now?: number;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/** One peer's line in the control screen. */
|
|
50
58
|
export interface PeerRow {
|
|
59
|
+
/** The raw peer id, so an action targets the right person; falls back to `label`. */
|
|
60
|
+
id?: string;
|
|
51
61
|
label: string;
|
|
52
62
|
status: "idle" | "queued" | "running";
|
|
53
63
|
queued: number;
|
|
54
64
|
runningMs?: number;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
/** How many peer rows the screen lists before
|
|
67
|
+
/** How many peer rows the screen lists before scrolling. */
|
|
58
68
|
const MAX_PEER_ROWS = 5;
|
|
59
69
|
|
|
60
70
|
/** The connection word for a bridge status, or `closed` when there is none. */
|
|
@@ -63,7 +73,11 @@ export function connectionOf(status: BridgeStatus | undefined): StatusConnection
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/** Build the view from the stored account and the live bridge status. */
|
|
66
|
-
export function statusView(
|
|
76
|
+
export function statusView(
|
|
77
|
+
account: BotCredentials | undefined,
|
|
78
|
+
status: BridgeStatus | undefined,
|
|
79
|
+
now: number = Date.now(),
|
|
80
|
+
): StatusView {
|
|
67
81
|
return {
|
|
68
82
|
bridge: status !== undefined,
|
|
69
83
|
account: account ? peerLabel(account.ilinkUserId) : undefined,
|
|
@@ -72,6 +86,7 @@ export function statusView(account: BotCredentials | undefined, status: BridgeSt
|
|
|
72
86
|
peers: status?.peerCount ?? 0,
|
|
73
87
|
queued: status?.queued ?? 0,
|
|
74
88
|
peerRows: (status?.peerStates ?? []).map((entry) => ({
|
|
89
|
+
id: entry.peer,
|
|
75
90
|
label: peerLabel(entry.peer),
|
|
76
91
|
status: entry.state.status,
|
|
77
92
|
queued: entry.state.queued,
|
|
@@ -81,6 +96,14 @@ export function statusView(account: BotCredentials | undefined, status: BridgeSt
|
|
|
81
96
|
refusedPeers: status?.refusedPeers ?? [],
|
|
82
97
|
mediaDropped: status?.mediaDropped ?? 0,
|
|
83
98
|
promptPending: status?.promptPending ?? false,
|
|
99
|
+
prompt: status?.prompt
|
|
100
|
+
? {
|
|
101
|
+
peer: sanitizeLine(peerLabel(status.prompt.peer)),
|
|
102
|
+
title: sanitizeLine(status.prompt.title),
|
|
103
|
+
startedAt: status.prompt.startedAt,
|
|
104
|
+
}
|
|
105
|
+
: undefined,
|
|
106
|
+
now,
|
|
84
107
|
};
|
|
85
108
|
}
|
|
86
109
|
|
|
@@ -140,8 +163,14 @@ function connectionRole(connection: StatusConnection): TuiRole {
|
|
|
140
163
|
}
|
|
141
164
|
}
|
|
142
165
|
|
|
166
|
+
/** The peer focus passed into the body: which row is selected and which slice shows. */
|
|
167
|
+
export interface PeerSelection {
|
|
168
|
+
selectedId?: string;
|
|
169
|
+
window?: { start: number; end: number };
|
|
170
|
+
}
|
|
171
|
+
|
|
143
172
|
/** Rows for the status screen body, each fitted to `width`. */
|
|
144
|
-
export function statusBody(view: StatusView, theme: Theme, width: number): string[] {
|
|
173
|
+
export function statusBody(view: StatusView, theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
145
174
|
const target = Math.max(1, width);
|
|
146
175
|
const lines = [
|
|
147
176
|
row(theme, target, "Account", view.account ?? "not logged in", view.account ? "label" : "meta"),
|
|
@@ -153,8 +182,13 @@ export function statusBody(view: StatusView, theme: Theme, width: number): strin
|
|
|
153
182
|
lines.push(row(theme, target, "Queued", String(view.queued), "meta"));
|
|
154
183
|
if (view.refused > 0) lines.push(row(theme, target, "Refused", refusedValue(view), "meta"));
|
|
155
184
|
if (view.mediaDropped > 0) lines.push(row(theme, target, "Media", `${view.mediaDropped} dropped`, "meta"));
|
|
156
|
-
if (view.
|
|
157
|
-
|
|
185
|
+
if (view.prompt) {
|
|
186
|
+
const age = formatDuration(Math.max(0, (view.now ?? Date.now()) - view.prompt.startedAt));
|
|
187
|
+
lines.push(row(theme, target, "Prompt", `${view.prompt.peer} · ${view.prompt.title} · ${age}`, "accent"));
|
|
188
|
+
} else if (view.promptPending) {
|
|
189
|
+
lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
|
|
190
|
+
}
|
|
191
|
+
lines.push(...peerLines(view.peerRows, theme, target, selection));
|
|
158
192
|
}
|
|
159
193
|
if (view.connection === "expired") {
|
|
160
194
|
lines.push("");
|
|
@@ -179,47 +213,98 @@ function peerValue(row: PeerRow): string {
|
|
|
179
213
|
return "idle";
|
|
180
214
|
}
|
|
181
215
|
|
|
182
|
-
/** One
|
|
183
|
-
function peerLines(rows: PeerRow[], theme: Theme, width: number): string[] {
|
|
216
|
+
/** One selectable line per peer, with a range row when the list is scrolled. */
|
|
217
|
+
function peerLines(rows: PeerRow[], theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
184
218
|
if (rows.length === 0) return [];
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
const start = selection?.window?.start ?? 0;
|
|
220
|
+
const end = selection?.window?.end ?? Math.min(rows.length, MAX_PEER_ROWS);
|
|
221
|
+
const shown = rows.slice(start, end);
|
|
222
|
+
const lines = ["", clip(` ${paint(theme, "meta", "Conversations")}`, width)];
|
|
223
|
+
for (const entry of shown) {
|
|
224
|
+
const selected = selection?.selectedId !== undefined && (entry.id ?? entry.label) === selection.selectedId;
|
|
189
225
|
const role: TuiRole = entry.status === "running" ? "accent" : entry.status === "queued" ? "warn" : "meta";
|
|
190
|
-
lines.push(
|
|
226
|
+
lines.push(
|
|
227
|
+
listRow(theme, width, {
|
|
228
|
+
indent: 2,
|
|
229
|
+
selected,
|
|
230
|
+
label: entry.label,
|
|
231
|
+
trailing: peerValue(entry),
|
|
232
|
+
trailingRole: role,
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
191
235
|
}
|
|
192
|
-
if (
|
|
236
|
+
if (selection?.window !== undefined) {
|
|
237
|
+
if (start > 0 || end < rows.length) {
|
|
238
|
+
lines.push(formatRange(theme, width, { start, end, total: rows.length }));
|
|
239
|
+
}
|
|
240
|
+
} else if (rows.length > shown.length) {
|
|
193
241
|
lines.push(clip(` ${paint(theme, "meta", `+${rows.length - shown.length} more`)}`, width));
|
|
194
242
|
}
|
|
195
243
|
return lines;
|
|
196
244
|
}
|
|
197
245
|
|
|
246
|
+
/** The peer actions the screen can run without closing. */
|
|
247
|
+
export interface StatusActions {
|
|
248
|
+
/** Stop the peer's running turn; returns the line to flash. */
|
|
249
|
+
stop(peer: string): string;
|
|
250
|
+
/** Start a fresh conversation for the peer; returns the line to flash. */
|
|
251
|
+
reset(peer: string): string;
|
|
252
|
+
/** Withdraw the waiting prompt; returns the line to flash. */
|
|
253
|
+
cancelPrompt(): string;
|
|
254
|
+
}
|
|
255
|
+
|
|
198
256
|
export interface StatusScreenOptions {
|
|
199
257
|
theme: Theme;
|
|
200
258
|
/** Live view, re-read on every render so `r` shows the current state. */
|
|
201
259
|
view: () => StatusView;
|
|
202
260
|
/** Called once when the screen closes, with the chosen action. */
|
|
203
261
|
onClose: (action: StatusAction) => void;
|
|
262
|
+
/** In-place peer actions; absent makes `s`/`d` explain that they are unavailable. */
|
|
263
|
+
actions?: StatusActions;
|
|
204
264
|
/** Repaint after a state change. */
|
|
205
265
|
requestRender: () => void;
|
|
206
266
|
}
|
|
207
267
|
|
|
208
268
|
/** The `/wechat` control screen: live connection facts plus every action. */
|
|
209
269
|
export class StatusScreenComponent implements Component {
|
|
270
|
+
private readonly cursor = new IdCursor();
|
|
271
|
+
private flash: string | undefined;
|
|
272
|
+
|
|
210
273
|
constructor(private readonly options: StatusScreenOptions) {}
|
|
211
274
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
275
|
+
/**
|
|
276
|
+
* The focused peer, or undefined when the list is empty. Not named `focused`:
|
|
277
|
+
* the host duck-types focusables with `"focused" in component` and writes a
|
|
278
|
+
* boolean there, which would shadow the method.
|
|
279
|
+
*/
|
|
280
|
+
private focusedPeer(view: StatusView): PeerRow | undefined {
|
|
281
|
+
const rows = view.peerRows;
|
|
282
|
+
if (rows.length === 0) return undefined;
|
|
283
|
+
return rows[this.cursor.indexOf(rows, (row) => row.id ?? row.label)];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private setFlash(message: string | undefined): void {
|
|
287
|
+
this.flash = message;
|
|
288
|
+
this.options.requestRender();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private hints(view: StatusView): ScreenHint[] {
|
|
292
|
+
const hints: ScreenHint[] = ["Esc close"];
|
|
293
|
+
if (view.peerRows.length > 0) {
|
|
294
|
+
hints.push("j/k move", { text: "s stop", color: "accent" }, { text: "d reset", color: "warning" }, "h history");
|
|
295
|
+
}
|
|
296
|
+
hints.push(
|
|
215
297
|
{ text: "l login", color: "accent" },
|
|
216
298
|
{ text: "n new", color: "accent" },
|
|
217
299
|
{ text: "o logout", color: "error" },
|
|
218
300
|
"r refresh",
|
|
219
|
-
|
|
301
|
+
);
|
|
302
|
+
return hints;
|
|
220
303
|
}
|
|
221
304
|
|
|
222
305
|
handleInput(data: string): void {
|
|
306
|
+
const view = this.options.view();
|
|
307
|
+
const peer = this.focusedPeer(view);
|
|
223
308
|
if (isCloseKey(data)) {
|
|
224
309
|
this.options.onClose("close");
|
|
225
310
|
return;
|
|
@@ -236,21 +321,97 @@ export class StatusScreenComponent implements Component {
|
|
|
236
321
|
this.options.onClose("logout");
|
|
237
322
|
return;
|
|
238
323
|
}
|
|
239
|
-
if (data === "r" || data === "R")
|
|
324
|
+
if (data === "r" || data === "R") {
|
|
325
|
+
this.setFlash(undefined);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (data === "a" || data === "A") {
|
|
329
|
+
this.options.onClose({ kind: "allow" });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (data === "c" || data === "C") {
|
|
333
|
+
if (!view.prompt) {
|
|
334
|
+
this.setFlash("No prompt waiting.");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.setFlash(this.options.actions?.cancelPrompt() ?? "Prompt cancel is unavailable.");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (data === "s" || data === "S" || data === "d" || data === "D" || data === "h" || data === "H") {
|
|
341
|
+
if (!peer) {
|
|
342
|
+
this.setFlash("No peer conversation yet.");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const id = peer.id ?? peer.label;
|
|
346
|
+
if (data === "h" || data === "H") {
|
|
347
|
+
this.options.onClose({ kind: "history", peer: id });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const actions = this.options.actions;
|
|
351
|
+
if (!actions) {
|
|
352
|
+
this.setFlash("Peer actions are unavailable here.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.setFlash(data === "s" || data === "S" ? actions.stop(id) : actions.reset(id));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const rows = view.peerRows;
|
|
359
|
+
const index = this.cursor.indexOf(rows, (row) => row.id ?? row.label);
|
|
360
|
+
switch (navIntent(data)) {
|
|
361
|
+
case "up":
|
|
362
|
+
this.select(index - 1, view);
|
|
363
|
+
return;
|
|
364
|
+
case "down":
|
|
365
|
+
this.select(index + 1, view);
|
|
366
|
+
return;
|
|
367
|
+
case "pageUp":
|
|
368
|
+
this.select(index - this.cursor.visible, view);
|
|
369
|
+
return;
|
|
370
|
+
case "pageDown":
|
|
371
|
+
this.select(index + this.cursor.visible, view);
|
|
372
|
+
return;
|
|
373
|
+
case "home":
|
|
374
|
+
this.select(0, view);
|
|
375
|
+
return;
|
|
376
|
+
case "end":
|
|
377
|
+
this.select(rows.length - 1, view);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
240
380
|
}
|
|
241
381
|
|
|
242
|
-
/**
|
|
382
|
+
/** Focus the row at `index` and repaint when it moves. */
|
|
383
|
+
private select(index: number, view: StatusView): void {
|
|
384
|
+
const rows = view.peerRows;
|
|
385
|
+
if (rows.length === 0) return;
|
|
386
|
+
this.cursor.select(rows, (row) => row.id ?? row.label, index);
|
|
387
|
+
this.options.requestRender();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The cursor is plain state and the view is live, so there is nothing to invalidate. */
|
|
243
391
|
invalidate(): void {}
|
|
244
392
|
|
|
245
393
|
render(width: number): string[] {
|
|
246
394
|
const target = Math.max(1, width);
|
|
247
395
|
const theme = this.options.theme;
|
|
248
|
-
|
|
396
|
+
const view = this.options.view();
|
|
397
|
+
const rows = view.peerRows;
|
|
398
|
+
this.cursor.visible = Math.min(MAX_PEER_ROWS, Math.max(1, rows.length));
|
|
399
|
+
this.cursor.clamp(rows, (row) => row.id ?? row.label);
|
|
400
|
+
const peer = this.focusedPeer(view);
|
|
401
|
+
if (rows.length > 0) this.cursor.ensureVisible(this.cursor.indexOf(rows, (row) => row.id ?? row.label));
|
|
402
|
+
const start = this.cursor.scroll;
|
|
403
|
+
const end = Math.min(rows.length, start + this.cursor.visible);
|
|
404
|
+
const selection: PeerSelection | undefined =
|
|
405
|
+
rows.length === 0
|
|
406
|
+
? undefined
|
|
407
|
+
: { selectedId: peer ? (peer.id ?? peer.label) : undefined, window: { start, end } };
|
|
408
|
+
const lines = [
|
|
249
409
|
titleRule(theme, target, `wechat${SEPARATORS.item}control`),
|
|
250
410
|
"",
|
|
251
|
-
...statusBody(
|
|
252
|
-
"",
|
|
253
|
-
screenHint(theme, target, this.hints()),
|
|
411
|
+
...statusBody(view, theme, target, selection),
|
|
254
412
|
];
|
|
413
|
+
if (this.flash) lines.push("", clip(` ${paint(theme, "meta", this.flash)}`, target));
|
|
414
|
+
lines.push("", screenHint(theme, target, this.hints(view)));
|
|
415
|
+
return lines;
|
|
255
416
|
}
|
|
256
417
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|