@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.
- package/README.md +13 -8
- package/README.zh-CN.md +5 -3
- package/assets/chrome.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +291 -207
- package/extensions/bytefly/doctor/runtime.ts +16 -0
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/segments.ts +3 -2
- package/extensions/bytefly/footer/types.ts +1 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +13 -3
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +201 -38
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +32 -0
- package/extensions/bytefly/update/register.ts +72 -0
- package/extensions/bytefly/update/state.ts +28 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +2 -2
- package/extensions/document/tool.ts +1 -3
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +39 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/memory/README.md +1 -1
- package/extensions/plan/README.md +24 -8
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +18 -12
- package/extensions/plan/plans.ts +1 -1
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +243 -0
- package/extensions/plan/runtime.ts +4 -0
- package/extensions/plan/steps.ts +34 -23
- package/extensions/plan/todos.ts +5 -58
- package/extensions/plan/tools.ts +66 -222
- package/extensions/plan/tui.ts +17 -9
- package/extensions/plan/types.ts +3 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +5 -0
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +100 -36
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/subagent/index.ts +0 -2
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/fetch/tool.ts +1 -3
- package/extensions/web-access/search/tool.ts +1 -3
- package/extensions/wechat/README.md +22 -11
- package/extensions/wechat/bridge.ts +90 -22
- package/extensions/wechat/commands.ts +45 -1
- package/extensions/wechat/doctor.ts +1 -0
- package/extensions/wechat/index.ts +3 -1
- package/extensions/wechat/status-screen.ts +32 -2
- package/extensions/wechat/tools.ts +0 -2
- package/lib/README.md +2 -1
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/requirements.ts +76 -2
- package/lib/todo-state.ts +62 -0
- package/lib/tui/glyphs.ts +1 -0
- package/package.json +1 -1
- package/assets/bytefly.schema.json +0 -785
package/extensions/todo/nudge.ts
CHANGED
|
@@ -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",
|
|
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
|
|
41
|
-
*
|
|
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(
|
|
44
|
-
|
|
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.
|
|
52
|
-
*
|
|
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;
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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: `
|
|
57
|
-
//
|
|
58
|
-
// `
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
noteWork: (fileWrite = false) => {
|
|
131
|
+
mutations++;
|
|
132
|
+
if (fileWrite) writes++;
|
|
124
133
|
},
|
|
125
|
-
nudgeDue: () => shouldNudge(
|
|
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
|
-
|
|
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": "
|
|
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`;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
`n` starts a new session, `o` logs out,
|
|
113
|
-
subcommands above are shortcuts for the same
|
|
114
|
-
the TUI; an unknown subcommand reports the
|
|
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":
|
|
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
|
-
-
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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();
|