@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
|
@@ -0,0 +1,122 @@
|
|
|
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?.paused) return "paused · run /wechat resume";
|
|
38
|
+
if (!status?.open) {
|
|
39
|
+
return status?.expired ? "expired · run /wechat login" : "closed";
|
|
40
|
+
}
|
|
41
|
+
const owner = status.owner ? peerLabel(status.owner) : "unclaimed";
|
|
42
|
+
const token = account ? `${formatDuration(Math.max(0, now - account.createdAt))} old` : "unknown age";
|
|
43
|
+
return `open · owner ${owner} · token ${token}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function poll(status: BridgeStatus | undefined, now: number): string {
|
|
47
|
+
if (!status) return "not started";
|
|
48
|
+
const last =
|
|
49
|
+
status.lastPollAt === undefined
|
|
50
|
+
? "no poll yet"
|
|
51
|
+
: `last ${formatDuration(Math.max(0, now - status.lastPollAt))} ago`;
|
|
52
|
+
const hold = status.pollHoldMs === undefined ? "server hold" : `hold ${formatDuration(status.pollHoldMs)}`;
|
|
53
|
+
const error = status.lastPollError ? ` · error: ${status.lastPollError}` : "";
|
|
54
|
+
return `${last} · ${hold}${error}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lockLine(status: BridgeStatus | undefined, now: number): string {
|
|
58
|
+
const lock = status?.lock;
|
|
59
|
+
if (!lock) return status?.open ? "not held" : "not running";
|
|
60
|
+
return `pid ${lock.pid} · ${age(now, lock.at)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function peers(status: BridgeStatus | undefined): string[] {
|
|
64
|
+
if (!status || status.peerStates.length === 0) return [row("Peers", "none")];
|
|
65
|
+
const running = status.peerStates.filter((entry) => entry.state.status === "running").length;
|
|
66
|
+
const lines = [row("Peers", `${status.peerStates.length} · ${running} running · ${status.queued} queued`)];
|
|
67
|
+
for (const entry of status.peerStates) {
|
|
68
|
+
const state = entry.state;
|
|
69
|
+
const what =
|
|
70
|
+
state.status === "running"
|
|
71
|
+
? `running ${formatDuration(state.runningMs ?? 0)}`
|
|
72
|
+
: state.status === "queued"
|
|
73
|
+
? `queued ${state.queued}`
|
|
74
|
+
: "idle";
|
|
75
|
+
const session = state.sessionAgeMs === undefined ? "" : ` · session ${formatDuration(state.sessionAgeMs)}`;
|
|
76
|
+
lines.push(row(` ${peerLabel(entry.peer)}`, `${what}${session}`));
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function recent(status: BridgeStatus | undefined): string {
|
|
82
|
+
const errors = [
|
|
83
|
+
status?.lastSendError ? `send: ${status.lastSendError}` : undefined,
|
|
84
|
+
status?.lastPollError ? `poll: ${status.lastPollError}` : undefined,
|
|
85
|
+
].filter((entry): entry is string => entry !== undefined);
|
|
86
|
+
return row("Recent", errors.length > 0 ? errors.join(" · ") : "no recent failure");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function media(status: BridgeStatus | undefined): string {
|
|
90
|
+
const dropped = status?.mediaDropped ?? 0;
|
|
91
|
+
const refused = status?.refused ?? 0;
|
|
92
|
+
const names = status?.refusedPeers.join(", ");
|
|
93
|
+
return row(
|
|
94
|
+
"Media",
|
|
95
|
+
`${dropped} dropped · ${refused} refused${names ? ` (${names})` : ""} · ${status?.promptPending ? "prompt waiting" : "no prompt"}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function configLine(config: WechatConfig): string {
|
|
100
|
+
const overrides = Object.keys(config.peers).length;
|
|
101
|
+
const limits = config.maxTurnsPerHour > 0 ? `${config.maxTurnsPerHour} turns/h` : "no turn cap";
|
|
102
|
+
return row(
|
|
103
|
+
"Config",
|
|
104
|
+
`typing ${config.typingIndicator ? "on" : "off"} · prompts ${config.remotePrompts ? "on" : "off"} · ` +
|
|
105
|
+
`${config.allowedPeers.length} allowed · ${overrides} peer override(s) · ${limits}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The doctor report, one line per fact. */
|
|
110
|
+
export function doctorReport(input: DoctorInput): string {
|
|
111
|
+
const { status, account, config, now } = input;
|
|
112
|
+
return [
|
|
113
|
+
"wechat — health",
|
|
114
|
+
row("Connection", connection(status, account, now)),
|
|
115
|
+
row("Poll", poll(status, now)),
|
|
116
|
+
row("Lock", lockLine(status, now)),
|
|
117
|
+
...peers(status),
|
|
118
|
+
recent(status),
|
|
119
|
+
media(status),
|
|
120
|
+
configLine(config),
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
@@ -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";
|
|
@@ -43,6 +43,8 @@ export function wechatChip(theme: Theme, state: BridgeState): string | undefined
|
|
|
43
43
|
return paint(theme, "ok", `${GLYPHS.wechat} wechat`);
|
|
44
44
|
case "expired":
|
|
45
45
|
return paint(theme, "bad", `${GLYPHS.wechat} wechat expired`);
|
|
46
|
+
case "paused":
|
|
47
|
+
return paint(theme, "warn", `${GLYPHS.wechat} wechat paused`);
|
|
46
48
|
case "stopped":
|
|
47
49
|
return undefined;
|
|
48
50
|
}
|
|
@@ -127,6 +129,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
127
129
|
acquireLock: () => acquireLock(lockPath(), lockDeps()),
|
|
128
130
|
refreshLock: () => refreshLock(lockPath(), lockDeps()),
|
|
129
131
|
releaseLock: () => releaseLock(lockPath(), process.pid),
|
|
132
|
+
loadLock: () => readLock(lockPath()),
|
|
130
133
|
notify: (message, kind) => {
|
|
131
134
|
ctxRef?.ui.notify(message, kind);
|
|
132
135
|
},
|
|
@@ -153,7 +156,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
153
156
|
const autoStart = (ctx: ExtensionContext): void => {
|
|
154
157
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
155
158
|
if (!primaryAccount()) return;
|
|
156
|
-
if (bridge?.status().open) return;
|
|
159
|
+
if (bridge?.status().open || bridge?.status().paused) return;
|
|
157
160
|
try {
|
|
158
161
|
bridge = bridge ?? build(ctx);
|
|
159
162
|
bridge.open();
|
|
@@ -180,6 +183,8 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
180
183
|
},
|
|
181
184
|
saveAccount,
|
|
182
185
|
primaryAccount,
|
|
186
|
+
readHistory: (peer, count) =>
|
|
187
|
+
readPeerHistory(ctxRef?.cwd ?? process.cwd(), join(wechatDir(), "sessions"), peer, count),
|
|
183
188
|
logout: () => {
|
|
184
189
|
clearCredentials();
|
|
185
190
|
// 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
|
};
|