@gavin-hu/my-pi-agent 0.11.3 → 0.12.0

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.
Files changed (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -18,12 +18,15 @@ import { TODO_TOOL } from "../../lib/tool-names.ts";
18
18
  import { currentTodo, hasOpenTodos } from "./state.ts";
19
19
  import type { Todo } from "./types.ts";
20
20
 
21
+ /** Tools that run a command, where reading and writing look the same by name. */
22
+ const SHELL_TOOL_NAMES: ReadonlySet<string> = new Set(["bash", "powershell"]);
23
+
21
24
  /**
22
25
  * Tools whose calls mean the working tree may have changed: the built-in
23
- * writers and shells. Kept explicit so a reader or an MCP tool that does not
26
+ * writers and the shells. Kept explicit so a reader or an MCP tool that does not
24
27
  * declare a hint cannot force a continuation.
25
28
  */
26
- export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit", "bash", "powershell"]);
29
+ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit", ...SHELL_TOOL_NAMES]);
27
30
 
28
31
  /**
29
32
  * Whether a finished tool call counts as work for lag detection. The `todo`
@@ -37,22 +40,68 @@ export function isMutatingTool(toolName: string, annotations?: ToolAnnotations):
37
40
  }
38
41
 
39
42
  /**
40
- * Whether a reminder is due: work happened since the last update, no reminder
41
- * was sent for it yet, and the list still has an unfinished item.
43
+ * Whether a finished tool call can have changed files, as opposed to a shell
44
+ * command that may only have read them. The reminder for a run that never
45
+ * started a list needs this: three `rg` calls are investigation, not unplanned
46
+ * work, and a shell call cannot be told apart from one that wrote. So it is a
47
+ * mutating call that is not a shell.
48
+ */
49
+ export function isFileWrite(toolName: string, annotations?: ToolAnnotations): boolean {
50
+ if (toolName === TODO_TOOL || SHELL_TOOL_NAMES.has(toolName)) return false;
51
+ return isMutatingTool(toolName, annotations);
52
+ }
53
+
54
+ /**
55
+ * Successful mutating calls needed before a run with no list at all is worth a
56
+ * reminder. A plan that was never started has no item to fall behind, and a
57
+ * false positive costs a whole extra model request, so one edit is not enough
58
+ * evidence that the work needed a list.
59
+ */
60
+ export const EMPTY_LIST_MUTATIONS = 3;
61
+
62
+ /** What the current turn knows about its work and the list. */
63
+ export interface NudgeState {
64
+ /** Successful mutating calls since the turn began or the list was last written. */
65
+ mutations: number;
66
+ /** How many of those can change files, as opposed to a shell command that may have read. */
67
+ writes: number;
68
+ /** Whether the model wrote or cleared the list since the turn began. */
69
+ touched: boolean;
70
+ /** Whether a reminder was already sent this turn. */
71
+ nudged: boolean;
72
+ /** The current list. */
73
+ todos: Todo[];
74
+ }
75
+
76
+ /**
77
+ * Whether a reminder is due. With an unfinished item, one mutation is enough.
78
+ * With no list at all, the turn must also have written something: repeated
79
+ * inspection with no file change is not unplanned work. A list the model wrote
80
+ * or cleared this turn is its own answer, so an explicit clear is never
81
+ * second-guessed.
42
82
  */
43
- export function shouldNudge(dirty: boolean, nudged: boolean, todos: Todo[]): boolean {
44
- return dirty && !nudged && hasOpenTodos(todos);
83
+ export function shouldNudge({ mutations, writes, touched, nudged, todos }: NudgeState): boolean {
84
+ if (nudged || mutations <= 0) return false;
85
+ if (hasOpenTodos(todos)) return true;
86
+ return todos.length === 0 && !touched && writes > 0 && mutations >= EMPTY_LIST_MUTATIONS;
45
87
  }
46
88
 
47
89
  /** Marker on the injected reminder, so it is recognizable and filterable. */
48
90
  export const TODO_NUDGE_MARKER = "[TODO SYNC]";
49
91
 
50
92
  /**
51
- * Model-facing reminder injected before settlement. Points at the item the list
52
- * currently considers active, so the model can correct it without rereading the
53
- * whole list.
93
+ * Model-facing reminder injected before settlement. For a list with unfinished
94
+ * work it points at the item the list currently considers active, so the model
95
+ * can correct it without rereading the whole list; for no list at all it asks
96
+ * for one.
54
97
  */
55
98
  export function formatNudge(todos: Todo[]): string {
99
+ if (todos.length === 0) {
100
+ return (
101
+ `${TODO_NUDGE_MARKER} Work happened but no todo list was recorded. ` +
102
+ "Call the todo tool with the steps of this work, then continue."
103
+ );
104
+ }
56
105
  const current = currentTodo(todos);
57
106
  const item = current ? (current.activeForm ?? current.content) : undefined;
58
107
  const pointer = item ? ` The list still points at "${item}".` : "";
@@ -4,10 +4,10 @@
4
4
  *
5
5
  * The list is reconstructed from the active branch on session start and tree
6
6
  * navigation, and the persistent widget mirrors it whenever it changes. Only
7
- * interactive (`tui`) sessions get a widget; the widget's row budget and
8
- * whether a finished list stays visible come from the todo config. The same
9
- * runtime carries the lag-tracking flags the `agent_before_settle` reminder
10
- * reads, so they reset with the session and the branch.
7
+ * interactive (`tui`) sessions get a widget; whether a finished list stays
8
+ * visible comes from the todo config. The same runtime carries the lag-tracking
9
+ * counters the `agent_before_settle` reminder reads, so they reset with the
10
+ * session and the branch.
11
11
  */
12
12
 
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -27,8 +27,8 @@ export interface TodoRuntime {
27
27
  reconstruct(ctx: ExtensionContext): void;
28
28
  /** Apply the widget config (loaded per session). */
29
29
  setConfig(config: TodoConfig): void;
30
- /** Record that a mutating tool ran since the last update. */
31
- noteWork(): void;
30
+ /** Record a successful mutating call; `fileWrite` marks one that can change files. */
31
+ noteWork(fileWrite?: boolean): void;
32
32
  /** Whether a lag reminder is due and has not been sent for it yet. */
33
33
  nudgeDue(): boolean;
34
34
  /** Mark the current lag as reminded, activating the injected reminder. */
@@ -53,11 +53,15 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
53
53
  let lastTuiCtx: ExtensionContext | undefined;
54
54
  // True while a dock screen owns the editor slot; the rail stays hidden.
55
55
  let suppressed = false;
56
- // Lag tracking: `dirty` is set when a mutating tool runs and cleared by a
57
- // todo update, `nudged` caps the reminder to one per user turn, and
58
- // `nudgeActive` marks an injected reminder as current so `context` keeps it
59
- // for the continuation and drops it on the next user turn.
60
- let dirty = false;
56
+ // Lag tracking: `mutations` counts successful mutating calls since the list
57
+ // was last written and `writes` how many of those can change files,
58
+ // `touched` records that the model wrote or cleared the list this turn (so an
59
+ // explicit clear is not second-guessed), `nudged` caps the reminder to one per
60
+ // user turn, and `nudgeActive` marks an injected reminder as current so
61
+ // `context` keeps it for the continuation and drops it on the next user turn.
62
+ let mutations = 0;
63
+ let writes = 0;
64
+ let touched = false;
61
65
  let nudged = false;
62
66
  let nudgeActive = false;
63
67
  // Screens watching the list (for example the open `/todos` screen) so they
@@ -104,13 +108,17 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
104
108
  todos = next;
105
109
  // A todo update is the model syncing the list, so the lag is cleared.
106
110
  // `nudged` is not: the reminder stays capped at one per user turn.
107
- dirty = false;
111
+ mutations = 0;
112
+ writes = 0;
113
+ touched = true;
108
114
  syncWidget(ctx);
109
115
  notify();
110
116
  },
111
117
  reconstruct: (ctx) => {
112
118
  todos = reconstructTodos(ctx.sessionManager.getBranch());
113
- dirty = false;
119
+ mutations = 0;
120
+ writes = 0;
121
+ touched = false;
114
122
  nudged = false;
115
123
  nudgeActive = false;
116
124
  syncWidget(ctx);
@@ -119,10 +127,11 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
119
127
  setConfig: (next) => {
120
128
  config = next;
121
129
  },
122
- noteWork: () => {
123
- dirty = true;
130
+ noteWork: (fileWrite = false) => {
131
+ mutations++;
132
+ if (fileWrite) writes++;
124
133
  },
125
- nudgeDue: () => shouldNudge(dirty, nudged, todos),
134
+ nudgeDue: () => shouldNudge({ mutations, writes, touched, nudged, todos }),
126
135
  markNudged: () => {
127
136
  nudged = true;
128
137
  nudgeActive = true;
@@ -132,7 +141,9 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
132
141
  },
133
142
  isNudgeActive: () => nudgeActive,
134
143
  resetNudge: () => {
135
- dirty = false;
144
+ mutations = 0;
145
+ writes = 0;
146
+ touched = false;
136
147
  nudged = false;
137
148
  },
138
149
  onChange: (listener) => {
@@ -96,7 +96,7 @@ and unknown keys are ignored.
96
96
  {
97
97
  "search": {
98
98
  "provider": "auto", // "auto" | "duckduckgo" | "searxng" | "brave"
99
- "endpoint": "http://localhost:8080", // SearXNG base URL; "" means SearXNG is unavailable
99
+ "endpoint": "", // SearXNG base URL, e.g. "http://localhost:8080"; "" means SearXNG is unavailable
100
100
  "language": "auto", // SearXNG: "auto" (zh-CN for Han, else en) | language code
101
101
  "safeSearch": 0, // SearXNG: 0 off, 1 moderate, 2 strict
102
102
  "categories": "general", // SearXNG categories
@@ -264,9 +264,9 @@ the content type and size.
264
264
 
265
265
  | Integration point | Value |
266
266
  |---|---|
267
- | Tools | `web_search`, `web_fetch`; `exposure: "direct"`, both `defaultActive: true` |
267
+ | Tools | `web_search`, `web_fetch`; direct exposure, active on registration (the defaults, so not declared) |
268
268
  | Execution mode | `web_search`: `sequential`; `web_fetch`: default |
269
- | Annotations | both `readOnlyHint: true`, `openWorldHint: true`, `destructiveHint: false` |
269
+ | Annotations | both `readOnlyHint: true`, `openWorldHint: true`; a read-only tool declares no `destructiveHint` |
270
270
  | Output | `outputSchema` (`WebSearchOutput` / `WebFetchOutput`) plus matching `structuredContent` |
271
271
  | State | none in the session branch; one fetch page cache per registration (`web_search` keeps a per-instance throttle) |
272
272
  | Lifecycle | the factory only registers (`index.ts`); `registerFetchTool` owns the page cache and clears it on `session_start` and idempotent `session_shutdown` |
@@ -191,9 +191,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
191
191
  ],
192
192
  parameters: WebFetchParams,
193
193
  outputSchema: WebFetchOutput,
194
- exposure: "direct",
195
- defaultActive: true,
196
- annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
194
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
195
  // The page cache is shared in-memory state created per registration, so
198
196
  // sibling calls must not run concurrently (matches web_search's throttle).
199
197
  executionMode: "sequential",
@@ -46,9 +46,7 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
46
46
  ],
47
47
  parameters: WebSearchParams,
48
48
  outputSchema: WebSearchOutput,
49
- exposure: "direct",
50
- defaultActive: true,
51
- annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
49
+ annotations: { readOnlyHint: true, openWorldHint: true },
52
50
  executionMode: "sequential",
53
51
 
54
52
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -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
@@ -262,7 +272,7 @@ overridden by the project `<cwd>/.pi/bytefly.json` (project wins):
262
272
  "maxInboundChars": 8000, // inbound text longer than this is truncated
263
273
  "typingIndicator": true, // show typing while a turn is generated
264
274
  "remotePrompts": true, // answer interactive dialogs on WeChat during a WeChat turn
265
- "pollHoldMs": 0 // experimental: ask the server for a shorter long-poll hold
275
+ // "pollHoldMs": 3000 // experimental: request a shorter long-poll hold; unset by default
266
276
  }
267
277
  ```
268
278
 
@@ -382,8 +392,9 @@ never touches the stored cursor.
382
392
  supplies a `context_token` only then).
383
393
  - Slash commands are fixed built-ins; there is no user-defined snippet or action
384
394
  list yet, and `/new`, `/stop`, `/status`, and `/help` are registered.
385
- - There is no pause. The bridge runs whenever an account is stored, and
386
- `/wechat logout` is the only way to stop it (and it discards the credentials).
395
+ - Pause is runtime-local. `/wechat pause` stops polling and releases the lock,
396
+ but a reload, `/new`, or restart builds a fresh running bridge; only
397
+ `/wechat logout` stops it persistently (and discards the credentials).
387
398
 
388
399
  ## Non-goals
389
400
 
@@ -395,16 +406,16 @@ sessions are in-process).
395
406
 
396
407
  | Integration point | Detail |
397
408
  |---|---|
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. |
399
- | Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
409
+ | 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. |
410
+ | Tools | `send_wechat` — owner-only proactive text; registered at factory time with direct exposure (the default), `executionMode: "sequential"`, `openWorldHint: true`. |
400
411
  | 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
412
  | 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
413
  | 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
414
  | 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
415
  | 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. |
416
+ | 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
417
  | 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. |
418
+ | 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
419
 
409
420
  ## Design notes
410
421
 
@@ -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();