@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.
@@ -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
- if (!existsSync(path)) return false;
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
  };