@gavin-hu/my-pi-agent 0.11.3 → 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.
@@ -76,6 +76,8 @@ How the `send_wechat` call and its result look in the transcript — the call li
76
76
  | `/wechat login` | QR login; on scan, stores credentials, replaces a running poller, and starts the bridge. |
77
77
  | `/wechat new` | Start a fresh *host* session. Peer conversations are independent and survive it; they use an inbound `/new`. |
78
78
  | `/wechat status` | The same control screen as a bare `/wechat` (the one-line summary while a peer turn runs). |
79
+ | `/wechat pause` | Stop polling new messages; running turns may finish and reply. The poller lock is released, so another runtime may take the account. |
80
+ | `/wechat resume` | Reacquire the lock and poll again; reports when another runtime already holds it. |
79
81
  | `/wechat doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
80
82
  | `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
81
83
  | `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
@@ -108,10 +110,18 @@ what is queued behind it), activity counters, and the waiting remote prompt
108
110
  `j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
109
111
  peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
110
112
  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.
113
+ senders to allow; `c` withdraws the waiting prompt; `p` pauses or resumes
114
+ polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
115
+ `r` refreshes, and Esc closes. The subcommands above are shortcuts for the same
116
+ actions and the only route outside the TUI; an unknown subcommand reports the
117
+ valid ones.
118
+
119
+ Pause is per runtime and keeps the account and peer sessions: a running turn
120
+ may finish and reply, a waiting prompt is withdrawn with the timeout notice,
121
+ and the poller lock is released so another runtime can take over. Resume
122
+ reacquires the lock and reports when another runtime holds it. `/reload` or a
123
+ restart builds a new bridge and resumes polling, so pause is a temporary mute,
124
+ not a persistent switch.
115
125
 
116
126
  A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
117
127
  command typed at the terminal declines while one is running: `status` prints the
@@ -395,16 +405,16 @@ sessions are in-process).
395
405
 
396
406
  | Integration point | Detail |
397
407
  |---|---|
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. |
408
+ | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
399
409
  | Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
400
410
  | Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
401
411
  | Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
402
412
  | Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
403
413
  | Slash commands | Admitted inbound text is matched against `SLASH_COMMANDS` in `handleMessage`; a snippet is expanded into the turn, an action runs out-of-band and replies, and an unknown command falls through. Actions act on the sender through `SlashSessionControls` (`newSession` → worker reset, `stop` → worker abort, `status` → worker state, `history` → a read-only read of that peer's newest session). |
404
414
  | Peer dialogs | `bindExtensions({ mode: "rpc", uiContext })` on each peer session, with `createPeerUIContext(channel)` routing `select`/`confirm`/`input`/`editor` to that peer and marking the context for `askHuman`'s file/text preface. The terminal's shared `ctx.ui` is deliberately not adapted: a host turn is never answered remotely, so its dialogs stay local even while a peer turn runs. |
405
- | Status | The bridge's connection state (`running` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, and the status bar renders it on line 2. |
415
+ | Status | The bridge's connection state (`running` / `paused` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, pause keeps a warning chip, and the status bar renders it on line 2. |
406
416
  | State | Files under `<agent-dir>/wechat/`; the bridge is not authoritative UI, so nothing is stored in the transcript. |
407
- | Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running (so the RPC double `session_start` on a session switch cannot start a second poller), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
417
+ | Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running or paused (so the RPC double `session_start` on a session switch cannot start a second poller or resume a paused one), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `pause()` stops the loop and releases the lock while keeping the worker, so a running turn can finish; `resume()` reacquires the lock and starts a new loop. Pause is runtime-local, so a reload or restart starts a fresh running bridge. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
408
418
 
409
419
  ## Design notes
410
420
 
@@ -65,7 +65,7 @@ export function isInboundMessage(message: WeixinMessage): message is WeixinMessa
65
65
  }
66
66
 
67
67
  /** The bridge's connection state, published to the UI for the status chip. */
68
- export type BridgeState = "stopped" | "running" | "expired";
68
+ export type BridgeState = "stopped" | "running" | "expired" | "paused";
69
69
 
70
70
  export interface BridgeDeps {
71
71
  config: WechatConfig;
@@ -107,6 +107,8 @@ export interface BridgeStatus {
107
107
  open: boolean;
108
108
  /** Whether the bridge stopped because the session expired and needs a re-login. */
109
109
  expired: boolean;
110
+ /** Whether the operator paused polling; the account and peer sessions are kept. */
111
+ paused: boolean;
110
112
  owner?: string;
111
113
  peerCount: number;
112
114
  queued: number;
@@ -146,6 +148,10 @@ export interface SendResult {
146
148
  export interface Bridge {
147
149
  open(): void;
148
150
  close(): Promise<void>;
151
+ /** Stop polling new messages; running turns may finish. False when not running. */
152
+ pause(): Promise<boolean>;
153
+ /** Reacquire the lock and start polling again. False when not paused or the lock is taken. */
154
+ resume(): Promise<boolean>;
149
155
  shutdown(): Promise<void>;
150
156
  /** Stop one peer's running turn; false when that peer is idle. */
151
157
  stop(peer: string): boolean;
@@ -165,6 +171,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
165
171
  const sleep = deps.sleep ?? defaultSleep;
166
172
  const armTimer = deps.armTimer ?? defaultTimer;
167
173
  let running = false;
174
+ let paused = false;
168
175
  let expired = false;
169
176
  let startedAt: number | undefined;
170
177
  let lastPollAt: number | undefined;
@@ -195,11 +202,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
195
202
 
196
203
  /** Report the current connection state to the host, for a status chip. */
197
204
  function publishState(): void {
198
- deps.onStateChange?.(running ? "running" : expired ? "expired" : "stopped");
205
+ deps.onStateChange?.(running ? "running" : expired ? "expired" : paused ? "paused" : "stopped");
199
206
  }
200
207
 
201
208
  function handleExpired(): void {
202
209
  running = false;
210
+ paused = false;
203
211
  expired = true;
204
212
  // An expiry can be discovered by a send while the loop is parked in
205
213
  // `getUpdates`; abort it so it cannot wake up beside a later `open()`.
@@ -491,6 +499,21 @@ export function createBridge(deps: BridgeDeps): Bridge {
491
499
  }
492
500
  }
493
501
 
502
+ /**
503
+ * Serialize lifecycle transitions. A pause and a resume from the control
504
+ * screen, or a shutdown racing either, must not leave two poll loops or a
505
+ * released lock behind a running bridge.
506
+ */
507
+ let lifecycle: Promise<void> = Promise.resolve();
508
+ function serial<T>(work: () => Promise<T>): Promise<T> {
509
+ const run = lifecycle.then(work, work);
510
+ lifecycle = run.then(
511
+ () => undefined,
512
+ () => undefined,
513
+ );
514
+ return run;
515
+ }
516
+
494
517
  return {
495
518
  open(): void {
496
519
  if (running) return;
@@ -523,6 +546,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
523
546
  deps.saveState(state);
524
547
  }
525
548
  running = true;
549
+ paused = false;
526
550
  expired = false;
527
551
  resetEphemeral();
528
552
  startedAt = deps.now();
@@ -563,28 +587,71 @@ export function createBridge(deps: BridgeDeps): Bridge {
563
587
  });
564
588
  },
565
589
 
566
- async close(): Promise<void> {
567
- if (!running && !abort) {
590
+ close(): Promise<void> {
591
+ return serial(async () => {
592
+ running = false;
593
+ paused = false;
594
+ expired = false;
595
+ abort?.abort();
596
+ abort = undefined;
597
+ try {
598
+ await loopPromise;
599
+ } catch {
600
+ // Errors are surfaced by the loop's own catch.
601
+ }
602
+ loopPromise = undefined;
603
+ // A paused bridge has no loop and no abort controller but still owns a
604
+ // worker, so close it unconditionally rather than leaking the sessions.
605
+ const closing = worker;
606
+ worker = undefined;
607
+ await closing?.close();
608
+ resetEphemeral();
609
+ publishState();
610
+ releaseSafe();
611
+ });
612
+ },
613
+
614
+ pause(): Promise<boolean> {
615
+ return serial(async () => {
616
+ if (!running) return false;
617
+ running = false;
618
+ paused = true;
619
+ // Clear the abort controller before withdrawing the prompt: its send must
620
+ // not be cancelled by the poll abort that follows.
621
+ const stopping = abort;
622
+ abort = undefined;
623
+ if (interaction.hasPending()) interaction.cancel();
624
+ stopping?.abort();
625
+ try {
626
+ await loopPromise;
627
+ } catch {
628
+ // Errors are surfaced by the loop's own catch.
629
+ }
630
+ loopPromise = undefined;
631
+ // No poller means no lock to keep: another runtime may take the account
632
+ // while this one is paused. Resume reacquires it and reports a conflict.
568
633
  releaseSafe();
569
634
  publishState();
570
- return;
571
- }
572
- running = false;
573
- expired = false;
574
- abort?.abort();
575
- abort = undefined;
576
- try {
577
- await loopPromise;
578
- } catch {
579
- // Errors are surfaced by the loop's own catch.
580
- }
581
- loopPromise = undefined;
582
- const closing = worker;
583
- worker = undefined;
584
- await closing?.close();
585
- resetEphemeral();
586
- publishState();
587
- releaseSafe();
635
+ return true;
636
+ });
637
+ },
638
+
639
+ resume(): Promise<boolean> {
640
+ return serial(async () => {
641
+ if (!paused || expired || !worker || !account) return false;
642
+ if (!deps.acquireLock()) {
643
+ notify("Another Pi session is already polling this WeChat account.", "warning");
644
+ return false;
645
+ }
646
+ paused = false;
647
+ running = true;
648
+ abort = new AbortController();
649
+ loopPromise = poll.run(abort.signal).catch((error) => {
650
+ notify(`WeChat bridge stopped: ${messageOf(error)}`, "error");
651
+ });
652
+ publishState();
653
+ return true;
654
+ });
588
655
  },
589
656
 
590
657
  async shutdown(): Promise<void> {
@@ -609,6 +676,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
609
676
  peerStates: states,
610
677
  open: running,
611
678
  expired,
679
+ paused,
612
680
  owner: state.ownerId ?? account?.ilinkUserId,
613
681
  peerCount: Object.keys(state.peers).length,
614
682
  queued: states.reduce((total, entry) => total + entry.state.queued, 0),
@@ -61,6 +61,8 @@ export const WECHAT_COMMANDS = [
61
61
  { verb: "login", description: "Log in by QR; on a scan, store the account and start the bridge" },
62
62
  { verb: "new", description: "Start a fresh host session (peer conversations are independent)" },
63
63
  { verb: "status", description: "Open the control screen (a one-line summary outside the TUI)" },
64
+ { verb: "pause", description: "Stop polling new messages; running turns may finish" },
65
+ { verb: "resume", description: "Resume polling after /wechat pause" },
64
66
  { verb: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
65
67
  { verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
66
68
  { verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
@@ -124,6 +126,36 @@ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void
124
126
  );
125
127
  }
126
128
 
129
+ /** Pause or resume polling; the account and peer sessions stay in place. */
130
+ async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume: boolean): Promise<void> {
131
+ const bridge = deps.getBridge();
132
+ if (!bridge) {
133
+ ctx.ui.notify("WeChat is not running.", "warning");
134
+ return;
135
+ }
136
+ if (resume) {
137
+ if (!bridge.status().paused) {
138
+ ctx.ui.notify("WeChat is not paused.");
139
+ return;
140
+ }
141
+ const ok = await bridge.resume();
142
+ ctx.ui.notify(
143
+ ok ? "WeChat polling resumed." : "WeChat could not resume; check the terminal notice.",
144
+ ok ? "info" : "warning",
145
+ );
146
+ return;
147
+ }
148
+ const ok = await bridge.pause();
149
+ ctx.ui.notify(
150
+ ok
151
+ ? "WeChat polling paused. Running turns may finish; no new messages will arrive."
152
+ : bridge.status().paused
153
+ ? "WeChat is already paused."
154
+ : "WeChat is not running.",
155
+ ok ? "info" : "warning",
156
+ );
157
+ }
158
+
127
159
  /** Add a refused sender to the project config after the operator confirms. */
128
160
  async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
129
161
  if (!ctx.hasUI) {
@@ -313,6 +345,14 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
313
345
  bridge.channel().cancel();
314
346
  return "Cancelled the waiting prompt.";
315
347
  },
348
+ togglePause: async () => {
349
+ const bridge = deps.getBridge();
350
+ if (!bridge) return "WeChat bridge is not running.";
351
+ if (bridge.status().paused) {
352
+ return (await bridge.resume()) ? "Resumed polling." : "Could not resume; see the notice.";
353
+ }
354
+ return (await bridge.pause()) ? "Paused polling. Running turns may finish." : "WeChat is not running.";
355
+ },
316
356
  },
317
357
  requestRender: () => tui.requestRender(),
318
358
  }),
@@ -410,6 +450,10 @@ async function dispatch(
410
450
  return runHistory(ctx, deps, peerArg);
411
451
  case "allow":
412
452
  return runAllow(ctx, deps, peerArg || undefined);
453
+ case "pause":
454
+ return runPauseToggle(ctx, deps, false);
455
+ case "resume":
456
+ return runPauseToggle(ctx, deps, true);
413
457
  case "stop":
414
458
  return runPeerAction(ctx, deps, "stop", peerArg);
415
459
  case "reset":
@@ -425,7 +469,7 @@ async function dispatch(
425
469
  export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
426
470
  pi.registerCommand("wechat", {
427
471
  description:
428
- "WeChat bridge. /wechat opens the control screen (login | new | status | doctor | stop | reset | history | allow | logout | help)",
472
+ "WeChat bridge. /wechat opens the control screen (login | new | status | pause | resume | doctor | stop | reset | history | allow | logout | help)",
429
473
  getArgumentCompletions: completeSubcommand,
430
474
  handler: async (args, ctx) => {
431
475
  const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -34,6 +34,7 @@ function age(now: number, at: number | undefined): string {
34
34
  }
35
35
 
36
36
  function connection(status: BridgeStatus | undefined, account: BotCredentials | undefined, now: number): string {
37
+ if (status?.paused) return "paused · run /wechat resume";
37
38
  if (!status?.open) {
38
39
  return status?.expired ? "expired · run /wechat login" : "closed";
39
40
  }
@@ -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
  }
@@ -154,7 +156,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
154
156
  const autoStart = (ctx: ExtensionContext): void => {
155
157
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
156
158
  if (!primaryAccount()) return;
157
- if (bridge?.status().open) return;
159
+ if (bridge?.status().open || bridge?.status().paused) return;
158
160
  try {
159
161
  bridge = bridge ?? build(ctx);
160
162
  bridge.open();
@@ -24,7 +24,7 @@ import { peerLabel } from "./format.ts";
24
24
  import type { BotCredentials } from "./types.ts";
25
25
 
26
26
  /** User-facing connection word; the internal `BridgeState` stays running/stopped. */
27
- export type StatusConnection = "open" | "expired" | "closed";
27
+ export type StatusConnection = "open" | "expired" | "paused" | "closed";
28
28
 
29
29
  /**
30
30
  * What the status screen closed with: a global action, a peer history request,
@@ -69,7 +69,7 @@ const MAX_PEER_ROWS = 5;
69
69
 
70
70
  /** The connection word for a bridge status, or `closed` when there is none. */
71
71
  export function connectionOf(status: BridgeStatus | undefined): StatusConnection {
72
- return status?.open ? "open" : status?.expired ? "expired" : "closed";
72
+ return status?.paused ? "paused" : status?.open ? "open" : status?.expired ? "expired" : "closed";
73
73
  }
74
74
 
75
75
  /** Build the view from the stored account and the live bridge status. */
@@ -146,6 +146,8 @@ function connectionText(connection: StatusConnection): string {
146
146
  return "open";
147
147
  case "expired":
148
148
  return "expired";
149
+ case "paused":
150
+ return "paused";
149
151
  case "closed":
150
152
  return "closed";
151
153
  }
@@ -158,6 +160,8 @@ function connectionRole(connection: StatusConnection): TuiRole {
158
160
  return "ok";
159
161
  case "expired":
160
162
  return "bad";
163
+ case "paused":
164
+ return "warn";
161
165
  case "closed":
162
166
  return "meta";
163
167
  }
@@ -193,6 +197,9 @@ export function statusBody(view: StatusView, theme: Theme, width: number, select
193
197
  if (view.connection === "expired") {
194
198
  lines.push("");
195
199
  lines.push(clip(` ${paint(theme, "bad", "Session expired — press l to log in again.")}`, target));
200
+ } else if (view.connection === "paused") {
201
+ lines.push("");
202
+ lines.push(clip(` ${paint(theme, "warn", "Paused — press p to resume polling.")}`, target));
196
203
  } else if (view.connection === "closed" && view.account) {
197
204
  lines.push("");
198
205
  lines.push(clip(` ${paint(theme, "meta", "Not connected — press l to log in again.")}`, target));
@@ -251,6 +258,8 @@ export interface StatusActions {
251
258
  reset(peer: string): string;
252
259
  /** Withdraw the waiting prompt; returns the line to flash. */
253
260
  cancelPrompt(): string;
261
+ /** Pause or resume polling; async because it settles the poll loop first. */
262
+ togglePause(): string | Promise<string>;
254
263
  }
255
264
 
256
265
  export interface StatusScreenOptions {
@@ -325,6 +334,27 @@ export class StatusScreenComponent implements Component {
325
334
  this.setFlash(undefined);
326
335
  return;
327
336
  }
337
+ if (data === "p" || data === "P") {
338
+ if (!view.bridge) {
339
+ this.setFlash("WeChat is not running.");
340
+ return;
341
+ }
342
+ const outcome = this.options.actions?.togglePause();
343
+ if (!outcome) {
344
+ this.setFlash("Pause is unavailable here.");
345
+ return;
346
+ }
347
+ if (typeof outcome === "string") {
348
+ this.setFlash(outcome);
349
+ return;
350
+ }
351
+ this.setFlash(view.connection === "paused" ? "Resuming…" : "Pausing…");
352
+ void outcome.then(
353
+ (line) => this.setFlash(line),
354
+ () => this.setFlash("Pause action failed."),
355
+ );
356
+ return;
357
+ }
328
358
  if (data === "a" || data === "A") {
329
359
  this.options.onClose({ kind: "allow" });
330
360
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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",