@gavin-hu/my-pi-agent 0.16.0 → 0.17.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 +14 -8
- package/README.zh-CN.md +14 -9
- package/extensions/bytefly/README.md +174 -101
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +10 -27
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +29 -1
- package/extensions/file-browse/client.ts +16 -463
- package/extensions/file-browse/html.ts +7 -5
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/router.ts +2 -2
- package/extensions/file-browse/styles.ts +8 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/index.ts +20 -45
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +88 -33
- package/extensions/plan/commands.ts +19 -4
- package/extensions/plan/config.ts +27 -0
- package/extensions/plan/plan-index.ts +3 -1
- package/extensions/plan/plans.ts +31 -13
- package/extensions/plan/review.ts +34 -14
- package/extensions/plan/runtime.ts +3 -1
- package/extensions/plan/steps.ts +43 -14
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +10 -10
- package/extensions/plan/tools/exit.ts +54 -43
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +22 -29
- package/extensions/plan/tools.ts +5 -2
- package/extensions/plan/tui.ts +9 -7
- package/extensions/plan/types.ts +8 -0
- package/extensions/plan/web-chrome.ts +101 -0
- package/extensions/plan/web-client.ts +98 -311
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +28 -2
- package/extensions/plan/web-router.ts +2 -2
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/subagent/README.md +25 -12
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/index.ts +10 -35
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +40 -18
- package/extensions/wechat/bridge.ts +38 -40
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +1 -1
- package/extensions/wechat/interaction.ts +25 -4
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +7 -5
- package/extensions/wechat/prompt.ts +19 -4
- package/extensions/wechat/qrcode.ts +9 -9
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -38
- package/lib/README.md +7 -6
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/interaction.ts +18 -1
- package/lib/report.ts +75 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +16 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +6 -2
- package/lib/tui/gauge.ts +9 -5
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +3 -1
- package/skills/changelog/SKILL.md +57 -45
- package/skills/next/SKILL.md +184 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
12
|
-
import { errorResult } from "../../lib/tool-result.ts";
|
|
12
|
+
import { errorResult, textResult } from "../../lib/tool-result.ts";
|
|
13
13
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
14
14
|
import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
|
|
15
15
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
@@ -82,62 +82,45 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
82
82
|
ctx as ExtensionContext,
|
|
83
83
|
);
|
|
84
84
|
const flags = `${call.caseSensitive ? "" : "i"}${watch.wake ? ", wake" : ""}`;
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
text: `Watching ${watch.id}: ${sourceLabel(watch)} → /${sanitizeLine(watch.pattern)}/${flags}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
|
|
90
|
-
},
|
|
91
|
-
],
|
|
92
|
-
details: { action: "add", watch } satisfies WatchDetails,
|
|
93
|
-
};
|
|
85
|
+
return textResult(
|
|
86
|
+
`Watching ${watch.id}: ${sourceLabel(watch)} → /${sanitizeLine(watch.pattern)}/${flags}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
|
|
87
|
+
{ action: "add", watch } satisfies WatchDetails,
|
|
88
|
+
);
|
|
94
89
|
}
|
|
95
90
|
|
|
96
91
|
case "list": {
|
|
97
92
|
const watches = runtime.list();
|
|
98
|
-
return {
|
|
99
|
-
content: [{ type: "text" as const, text: formatWatchList(watches) }],
|
|
100
|
-
details: { action: "list", watches } satisfies WatchDetails,
|
|
101
|
-
};
|
|
93
|
+
return textResult(formatWatchList(watches), { action: "list", watches } satisfies WatchDetails);
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
case "status": {
|
|
105
97
|
const watch = runtime.get(call.id);
|
|
106
98
|
if (!watch) return errorResult("status", `no watcher "${call.id}".`);
|
|
107
|
-
return {
|
|
108
|
-
content: [{ type: "text" as const, text: formatWatchStatus(watch) }],
|
|
109
|
-
details: { action: "status", watch } satisfies WatchDetails,
|
|
110
|
-
};
|
|
99
|
+
return textResult(formatWatchStatus(watch), { action: "status", watch } satisfies WatchDetails);
|
|
111
100
|
}
|
|
112
101
|
|
|
113
102
|
case "matches": {
|
|
114
103
|
const result = runtime.matches(call.id, call.lines);
|
|
115
104
|
if (!result) return errorResult("matches", `no watcher "${call.id}".`);
|
|
116
105
|
const text = formatTail("matches", result.watch, result.lines, 4000);
|
|
117
|
-
return {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
truncated: text.truncated,
|
|
124
|
-
} satisfies WatchDetails,
|
|
125
|
-
};
|
|
106
|
+
return textResult(text.text, {
|
|
107
|
+
action: "matches",
|
|
108
|
+
watch: result.watch,
|
|
109
|
+
matches: result.lines.join("\n"),
|
|
110
|
+
truncated: text.truncated,
|
|
111
|
+
} satisfies WatchDetails);
|
|
126
112
|
}
|
|
127
113
|
|
|
128
114
|
case "logs": {
|
|
129
115
|
const result = runtime.logs(call.id, call.lines);
|
|
130
116
|
if (!result) return errorResult("logs", `no watcher "${call.id}".`);
|
|
131
117
|
const text = formatTail("output", result.watch, result.lines, 4000);
|
|
132
|
-
return {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
truncated: text.truncated,
|
|
139
|
-
} satisfies WatchDetails,
|
|
140
|
-
};
|
|
118
|
+
return textResult(text.text, {
|
|
119
|
+
action: "logs",
|
|
120
|
+
watch: result.watch,
|
|
121
|
+
logs: result.lines.join("\n"),
|
|
122
|
+
truncated: text.truncated,
|
|
123
|
+
} satisfies WatchDetails);
|
|
141
124
|
}
|
|
142
125
|
|
|
143
126
|
case "stop": {
|
|
@@ -145,34 +128,23 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
145
128
|
if (!before) return errorResult("stop", `no watcher "${call.id}".`);
|
|
146
129
|
const stopped = runtime.stop(call.id);
|
|
147
130
|
const watch = runtime.get(call.id) ?? before;
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
: `${watch.id} was not running; nothing to stop.`,
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
details: { action: "stop", watch, stopped } satisfies WatchDetails,
|
|
158
|
-
};
|
|
131
|
+
return textResult(
|
|
132
|
+
stopped
|
|
133
|
+
? `Stopped ${watch.id} (${sourceLabel(watch)}).`
|
|
134
|
+
: `${watch.id} was not running; nothing to stop.`,
|
|
135
|
+
{ action: "stop", watch, stopped } satisfies WatchDetails,
|
|
136
|
+
);
|
|
159
137
|
}
|
|
160
138
|
|
|
161
139
|
case "clear": {
|
|
162
140
|
const result = runtime.clear(call.id, call.all ?? false);
|
|
163
141
|
if (result.refused) return errorResult("clear", result.refused);
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
? "Nothing to clear."
|
|
171
|
-
: `Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`,
|
|
172
|
-
},
|
|
173
|
-
],
|
|
174
|
-
details: { action: "clear", cleared: result.cleared, watches: runtime.list() } satisfies WatchDetails,
|
|
175
|
-
};
|
|
142
|
+
return textResult(
|
|
143
|
+
result.cleared === 0
|
|
144
|
+
? "Nothing to clear."
|
|
145
|
+
: `Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`,
|
|
146
|
+
{ action: "clear", cleared: result.cleared, watches: runtime.list() } satisfies WatchDetails,
|
|
147
|
+
);
|
|
176
148
|
}
|
|
177
149
|
}
|
|
178
150
|
} catch (error) {
|
|
@@ -47,8 +47,9 @@ How the `send_wechat` call and its result look in the transcript — the call li
|
|
|
47
47
|
- **Peer feedback** — a burst that arrives while a turn is still running is
|
|
48
48
|
answered once, a message waiting on another conversation is acknowledged once,
|
|
49
49
|
a slow turn says so, and a turn that ends without text says so instead of going
|
|
50
|
-
quiet. Notices and `/help` follow the peer's language
|
|
51
|
-
|
|
50
|
+
quiet. Notices and `/help` follow the peer's language: a Chinese message sets it,
|
|
51
|
+
and a language-neutral reply (`1`, `ok`) does not switch it back. The peer can
|
|
52
|
+
also stop (`/stop`), restart (`/new`), and ask what is happening (`/status`).
|
|
52
53
|
- **Remote prompts** — the peer session's own UI is bound to that peer, so
|
|
53
54
|
`ask_user_question`, plan approval, and any other dialog raised inside a
|
|
54
55
|
WeChat turn is sent to WeChat and answered there. Terminal turns keep the TUI,
|
|
@@ -174,14 +175,14 @@ needs. Per action:
|
|
|
174
175
|
|---|---|
|
|
175
176
|
| Sends a message | The native typing indicator starts as soon as the message is picked up; the answer is sent when the run settles. |
|
|
176
177
|
| Sends several messages quickly | At most one acknowledgement, then a single answer that covers the burst in order. |
|
|
177
|
-
| Waits behind a long turn | The indicator keeps refreshing, and after four seconds one short
|
|
178
|
+
| Waits behind a long turn | The indicator keeps refreshing, and after four seconds one short `收到,正在排队,轮到就回你。` / “Got it — queued; I’ll answer when it’s my turn.”; a turn still running at 30 seconds adds one `还在处理这条,发送 /stop 可取消。` / “Still working — send /stop to cancel.”, so the way out is named when it is wanted. |
|
|
178
179
|
| Sends while another peer's turn runs | One line after four seconds, then one more at a minute (`前面还有一条正在处理…` / “Your message is queued behind another conversation…”), and the answer when the slot frees. |
|
|
179
180
|
| Sends a photo or a file | The model reads it; an attachment that could not be read (too large, a failed download, no key) is named in the answer. |
|
|
180
181
|
| Sends a voice note or video | One line: voice without a transcript and video are not supported. |
|
|
181
182
|
| Reads a long answer | Streamed as it is produced — one part per paragraph, per two silent seconds, or per 400 characters — and numbered (`[1/3] …`) only when a single part exceeds `maxReplyChars`. |
|
|
182
183
|
| Reads an answer | Plain chat text — Markdown fences, headings, and emphasis markers are flattened, code-block bodies survive. |
|
|
183
|
-
| Is asked a question | A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at
|
|
184
|
-
| Types `/help`, `/new`, `/stop`, or `/status` | The command list, a fresh conversation, a stopped answer, or the current state; `/new` is declined while their run is in flight. |
|
|
184
|
+
| Is asked a question | A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at the halfway point of its window and then cancelled: three minutes for a dialog, ten for a plan review, where the plan has to be read first. |
|
|
185
|
+
| Types `/help`, `/new`, `/stop`, or `/status` | The command list, a fresh conversation, a stopped answer, or the current state — including the dialog a blocked turn is waiting on; `/new` is declined while their run is in flight. A command works while a dialog waits: it is never read as an answer to it. |
|
|
185
186
|
| Types `/history` | The last eight messages of this conversation (or the requested number, up to 20), one line each. |
|
|
186
187
|
| Floods the chat | Past eight queued messages, one line asking to wait rather than an unbounded queue. |
|
|
187
188
|
| Sends the same message twice (a server replay) | Answered once: the WeChat message id is remembered for an hour, across a reconnect or reload. |
|
|
@@ -191,9 +192,10 @@ needs. Per action:
|
|
|
191
192
|
| Gets a turn that could not start | `处理失败,请稍后重试。` / “Something went wrong — please try again.”, and the terminal says why. |
|
|
192
193
|
| Keeps messaging an expired or logged-out bridge | Nothing — the bot goes quiet, and only the terminal chip says why. |
|
|
193
194
|
|
|
194
|
-
Notices are one line and in the peer's language (Chinese
|
|
195
|
-
|
|
196
|
-
chatter: WeChat notifies per message,
|
|
195
|
+
Notices are one line and in the peer's language (Chinese once their messages
|
|
196
|
+
contain Chinese; a bare `1` or `ok` does not switch it back). The bridge never
|
|
197
|
+
sends greetings, read receipts, or progress chatter: WeChat notifies per message,
|
|
198
|
+
so every bubble has to carry information.
|
|
197
199
|
|
|
198
200
|
### The reply contract
|
|
199
201
|
|
|
@@ -204,7 +206,7 @@ inside the answer it is already waiting for — not with a second “working on
|
|
|
204
206
|
| Situation | What the peer gets |
|
|
205
207
|
|---|---|
|
|
206
208
|
| 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
|
|
207
|
-
| 1st message, peer busy | typing,
|
|
209
|
+
| 1st message, peer busy | typing, `收到,正在排队,轮到就回你。` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
|
|
208
210
|
| 2nd message after the acknowledgement, still queued | nothing new; it is merged into the same turn and the one answer covers both in order |
|
|
209
211
|
| 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
|
|
210
212
|
| 3rd…Nth in a burst | same as the queued case; past eight queued, one line asking to wait |
|
|
@@ -217,17 +219,33 @@ starts, and a reply is delivered when the run settles, not token by token.
|
|
|
217
219
|
|
|
218
220
|
A dialog raised while a WeChat turn is in flight is forwarded to the sender:
|
|
219
221
|
|
|
220
|
-
- `select` — a numbered list; reply with a number
|
|
221
|
-
|
|
222
|
+
- `select` — a numbered list; reply with a number or the option text. Free text
|
|
223
|
+
becomes the “Other” answer only when the list carries that entry; the hint says
|
|
224
|
+
so, so a plan review without it does not pretend free text is a choice.
|
|
222
225
|
- `confirm` — reply 是/否, 确定/不是, or yes/no.
|
|
223
226
|
- `input` / `editor` — reply with the text.
|
|
224
|
-
- Reply 取消 (or cancel) to dismiss
|
|
227
|
+
- Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
|
|
228
|
+
`exit plan mode` are recognized as cancel intent. In a plan review a dismissal
|
|
229
|
+
leaves plan mode without approving.
|
|
225
230
|
- Hints are bilingual (Chinese and English). A reply that cannot be read
|
|
226
231
|
re-sends the prompt with a short “didn’t recognize that” notice instead of
|
|
227
232
|
repeating it silently.
|
|
233
|
+
- A command is not an answer. While a dialog waits the model is blocked on it, so
|
|
234
|
+
the slash commands are matched first: `/status` names the waiting question and
|
|
235
|
+
`/stop` stops the turn, where before both were parsed as an attempt to answer
|
|
236
|
+
and bounced back as “didn’t recognize that”. Any other text is still the answer,
|
|
237
|
+
so a number, 是/否, or 取消 behaves as above.
|
|
228
238
|
- An unanswered prompt is bounded: a reminder goes out halfway and the question
|
|
229
|
-
is cancelled
|
|
230
|
-
cannot freeze the session.
|
|
239
|
+
is cancelled at the end of its window with a one-line notice, so an ignored
|
|
240
|
+
dialog cannot freeze the session. The window is three minutes for a dialog and
|
|
241
|
+
ten for the plan review, which arrives with the plan attached — a request that
|
|
242
|
+
carries `timeoutMs` asks for the longer one through the channel, and the
|
|
243
|
+
reminder names 取消 rather than a duration that assumes the short window. A
|
|
244
|
+
timed-out plan review is treated like a dismissal: plan mode ends without
|
|
245
|
+
approval, and the plan file remains.
|
|
246
|
+
- A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
|
|
247
|
+
says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
|
|
248
|
+
timeout line: the peer was not slow, the question left.
|
|
231
249
|
|
|
232
250
|
Each peer session is created with its own UI context (`bindExtensions` with
|
|
233
251
|
`mode: "rpc"`), so its dialogs go to that peer by construction rather than by
|
|
@@ -240,15 +258,19 @@ to WeChat. See [Design notes](#design-notes).
|
|
|
240
258
|
## Outbound files
|
|
241
259
|
|
|
242
260
|
A remote prompt can deliver a local file first. `exit_plan_mode` uses this to
|
|
243
|
-
send the plan file itself before the
|
|
261
|
+
send the plan file itself before the five choices, so WeChat sees the whole plan
|
|
244
262
|
(a long one falls back to chunked text if the upload fails). The peer session's
|
|
245
263
|
UI context carries the bridge channel, so `askHuman` finds it and posts the file
|
|
246
264
|
then the text before the dialogs. Before the choices it also posts a short
|
|
247
265
|
digest — the title, the step count, and the first steps — so the review is
|
|
248
266
|
answerable from the chat even if the file bubble is never opened, and a file or
|
|
249
267
|
digest the bridge cannot deliver is reported on the terminal instead of vanishing.
|
|
250
|
-
The
|
|
251
|
-
|
|
268
|
+
The digest says plan mode is on and that write access is off. The choices also
|
|
269
|
+
include **Send the full plan**, which posts the plan text and asks again, so the
|
|
270
|
+
whole plan is reachable without the attachment, and **Exit plan mode (discard)**,
|
|
271
|
+
which leaves without approving. After a Keep decision the user can say “exit plan
|
|
272
|
+
mode”; the model calls `cancel_plan_mode`, which needs no plan path and records
|
|
273
|
+
no todo or goal.
|
|
252
274
|
The flow mirrors the
|
|
253
275
|
reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
|
|
254
276
|
(`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
|
|
@@ -507,7 +529,7 @@ status, which is what the old start/stop guidance used to do.
|
|
|
507
529
|
| `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
|
|
508
530
|
| `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
|
|
509
531
|
| `login.ts` | QR login state machine. |
|
|
510
|
-
| `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
|
|
532
|
+
| `qrcode.ts` | QR matrix → Unicode half-block terminal lines; the `qrcode` package is imported on first use, not at registration. |
|
|
511
533
|
| `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
|
|
512
534
|
| `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
|
|
513
535
|
| `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { WechatError, type WechatClient } from "./client.ts";
|
|
20
20
|
import { createAdmissionQueue, createTurnBudget } from "./admission.ts";
|
|
21
|
-
import { createDeliverer } from "./deliver.ts";
|
|
21
|
+
import { createDeliverer, type MessageTarget } from "./deliver.ts";
|
|
22
22
|
import { createInboundAdmission } from "./inbound.ts";
|
|
23
23
|
import { createPollLoop, defaultPollTimeout } from "./poll.ts";
|
|
24
24
|
import { messageOf } from "./errors.ts";
|
|
@@ -225,8 +225,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/** Send one bubble to a peer and report whether it was delivered. */
|
|
228
|
-
async function reply(
|
|
229
|
-
return deliverer.reply(
|
|
228
|
+
async function reply(target: MessageTarget, text: string): Promise<boolean> {
|
|
229
|
+
return deliverer.reply(target, text, abort?.signal);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
/** The outbound surface, reading the live client and account at call time. */
|
|
@@ -248,30 +248,34 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
251
|
-
async function runSlashAction(command: SlashCommand,
|
|
251
|
+
async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
|
|
252
252
|
if (!command.run) return;
|
|
253
253
|
const ctx: SlashContext = {
|
|
254
254
|
args,
|
|
255
|
-
peer:
|
|
255
|
+
peer: target.peer,
|
|
256
256
|
reply: async (text) => {
|
|
257
|
-
await reply(
|
|
257
|
+
await reply(target, text);
|
|
258
258
|
},
|
|
259
|
-
activeTurn: worker?.state(
|
|
260
|
-
language: languageOf(
|
|
259
|
+
activeTurn: worker?.state(target.peer).status === "running",
|
|
260
|
+
language: languageOf(target.peer),
|
|
261
261
|
session: {
|
|
262
262
|
newSession: () => {
|
|
263
|
-
worker?.reset(
|
|
263
|
+
worker?.reset(target.peer);
|
|
264
264
|
},
|
|
265
|
-
stop: () => worker?.abort(
|
|
266
|
-
status: () =>
|
|
267
|
-
|
|
265
|
+
stop: () => worker?.abort(target.peer) ?? false,
|
|
266
|
+
status: () => {
|
|
267
|
+
const current = worker?.state(target.peer) ?? { status: "idle" as const, queued: 0 };
|
|
268
|
+
const waiting = interaction.pendingInfo();
|
|
269
|
+
return waiting?.peer === target.peer ? { ...current, prompt: waiting.title } : current;
|
|
270
|
+
},
|
|
271
|
+
history: (count) => deps.readHistory(target.peer, count),
|
|
268
272
|
},
|
|
269
273
|
};
|
|
270
274
|
try {
|
|
271
275
|
await command.run(ctx);
|
|
272
276
|
} catch (error) {
|
|
273
277
|
notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
|
|
274
|
-
await reply(
|
|
278
|
+
await reply(target, `Command /${command.name} failed.`);
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
|
|
@@ -305,11 +309,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
305
309
|
|
|
306
310
|
/** The peer's language: from this text, else the last one they wrote. */
|
|
307
311
|
function languageOf(peer: string, text?: string): PeerLanguage {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
312
|
+
// A Chinese message is evidence of Chinese. Its absence is not evidence of
|
|
313
|
+
// English, or a short reply ("ok", "1") would flip an established conversation
|
|
314
|
+
// back to English; English is the fallback, so it needs no record of its own.
|
|
315
|
+
if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
|
|
313
316
|
return languages.get(peer) ?? "en";
|
|
314
317
|
}
|
|
315
318
|
|
|
@@ -386,26 +389,11 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
386
389
|
if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
|
|
387
390
|
deps.saveState(state);
|
|
388
391
|
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
peer,
|
|
395
|
-
contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken,
|
|
396
|
-
text,
|
|
397
|
-
media,
|
|
398
|
-
language,
|
|
399
|
-
};
|
|
400
|
-
if (!text && media.length === 0) {
|
|
401
|
-
// Nothing to read. A voice note without a transcript or a video is
|
|
402
|
-
// answered, so the peer knows it did not reach the agent.
|
|
403
|
-
if (unsupported > 0) void reply(item, notice("unsupported", language));
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
// Intercept a text slash command before it becomes a turn. A message with
|
|
407
|
-
// media is never a command, so an attachment is never dropped; an unknown
|
|
408
|
-
// command falls through to the model as ordinary text.
|
|
392
|
+
const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
|
|
393
|
+
// A command is not an answer: while a dialog waits the model is blocked on it,
|
|
394
|
+
// so /stop and /status must reach their handlers or the peer's only way out is
|
|
395
|
+
// the prompt timing out. A message with media is never a command, and an
|
|
396
|
+
// unknown command falls through to the model as ordinary text.
|
|
409
397
|
const slash = media.length === 0 ? parseSlash(text) : undefined;
|
|
410
398
|
const command = slash ? findSlashCommand(slashCommands, slash.name) : undefined;
|
|
411
399
|
if (command && slash) {
|
|
@@ -415,12 +403,22 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
415
403
|
}
|
|
416
404
|
if (command.kind === "snippet") {
|
|
417
405
|
text = expandSnippet(command, slash.args);
|
|
418
|
-
item.text = text;
|
|
419
406
|
} else {
|
|
420
|
-
void runSlashAction(command,
|
|
407
|
+
void runSlashAction(command, target, slash.args);
|
|
421
408
|
return;
|
|
422
409
|
}
|
|
423
410
|
}
|
|
411
|
+
// A reply to a pending prompt is consumed here, before queueing, so it never
|
|
412
|
+
// starts a turn of its own. A duplicate answer was stopped by the id check.
|
|
413
|
+
if (interaction.handleInbound(peer, text)) return;
|
|
414
|
+
const language = languageOf(peer, text);
|
|
415
|
+
const item: InboundItem = { ...target, text, media, language };
|
|
416
|
+
if (!text && media.length === 0) {
|
|
417
|
+
// Nothing to read. A voice note without a transcript or a video is
|
|
418
|
+
// answered, so the peer knows it did not reach the agent.
|
|
419
|
+
if (unsupported > 0) void reply(item, notice("unsupported", language));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
424
422
|
// A slot is reserved here, before the download, so a burst of media cannot
|
|
425
423
|
// all pass the check and then enqueue together.
|
|
426
424
|
if (!budget.allow(peer)) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { asNumber } from "../../lib/config.ts";
|
|
12
13
|
import { mediaDownloadUrl } from "./media.ts";
|
|
13
14
|
import type {
|
|
14
15
|
BotCredentials,
|
|
@@ -120,10 +121,6 @@ function parseJson(text: string): Record<string, unknown> {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
function asNumber(value: unknown): number | undefined {
|
|
124
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
124
|
function asString(value: unknown): string | undefined {
|
|
128
125
|
return typeof value === "string" && value !== "" ? value : undefined;
|
|
129
126
|
}
|
|
@@ -292,7 +292,7 @@ async function runLogin(ctx: ExtensionContext, deps: CommandDeps): Promise<void>
|
|
|
292
292
|
return;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const state: LoginViewState = { status: "wait", lines: qrLines(qr.imgContent), url: qr.imgContent };
|
|
295
|
+
const state: LoginViewState = { status: "wait", lines: await qrLines(qr.imgContent), url: qr.imgContent };
|
|
296
296
|
const result = await ctx.ui.custom<LoginOutcome | undefined>((tui, theme, _keybindings, done) => {
|
|
297
297
|
const controller = new AbortController();
|
|
298
298
|
void (async () => {
|
|
@@ -43,6 +43,11 @@ export interface WechatInteractionDeps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface WechatInteractionChannel extends InteractionChannel {
|
|
46
|
+
/**
|
|
47
|
+
* Widen the bound for the prompts asked inside the scope, and restore the
|
|
48
|
+
* previous one when it ends: the optional channel member, made concrete here.
|
|
49
|
+
*/
|
|
50
|
+
setPromptTimeout(ms: number): () => void;
|
|
46
51
|
/** Consume an inbound message as a reply; true when it was one. */
|
|
47
52
|
handleInbound(peer: string, text: string): boolean;
|
|
48
53
|
/** Whether a prompt is currently waiting for a reply. */
|
|
@@ -79,6 +84,11 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
79
84
|
const arm = deps.armTimer ?? defaultTimer;
|
|
80
85
|
const now = deps.now ?? Date.now;
|
|
81
86
|
let pending: Pending | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* The bound for the prompts asked inside the current `setPromptTimeout` scope,
|
|
89
|
+
* set by a caller whose dialog needs reading time before it can be answered.
|
|
90
|
+
*/
|
|
91
|
+
let scopedTimeoutMs: number | undefined;
|
|
82
92
|
/**
|
|
83
93
|
* Free text captured from a `select` "Other" reply, per peer: the immediately
|
|
84
94
|
* following `input` for that peer returns it. Keyed so one peer's free text can
|
|
@@ -119,9 +129,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
119
129
|
|
|
120
130
|
cancel(): void {
|
|
121
131
|
if (!pending) return;
|
|
122
|
-
// The peer saw the prompt; one line says it was withdrawn
|
|
132
|
+
// The peer saw the prompt; one line says it was withdrawn, which is not the
|
|
133
|
+
// same as expiry — they were not slow, the question went away. `reset` is the
|
|
123
134
|
// silent cleanup path and deliberately does not send this.
|
|
124
|
-
void deps.send(notice("
|
|
135
|
+
void deps.send(notice("withdrawn", deps.language()));
|
|
125
136
|
settle({ kind: "cancelled" });
|
|
126
137
|
},
|
|
127
138
|
|
|
@@ -133,6 +144,14 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
133
144
|
return deps.sendFile(path, name, signal);
|
|
134
145
|
},
|
|
135
146
|
|
|
147
|
+
setPromptTimeout(ms: number): () => void {
|
|
148
|
+
const previous = scopedTimeoutMs;
|
|
149
|
+
scopedTimeoutMs = ms;
|
|
150
|
+
return () => {
|
|
151
|
+
scopedTimeoutMs = previous;
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
|
|
136
155
|
async request(request: InteractionRequest, signal?: AbortSignal): Promise<InteractionAnswer> {
|
|
137
156
|
// A `select` that resolved to "Other" stashes its text for that peer; the
|
|
138
157
|
// immediately following `input` from the same peer returns it without
|
|
@@ -158,9 +177,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
158
177
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
159
178
|
// An ignored question must not freeze the session: nudge once, then
|
|
160
179
|
// cancel so the model gets a clean answer instead of a hang.
|
|
161
|
-
|
|
180
|
+
const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
181
|
+
cancelTimers.push(arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()))));
|
|
162
182
|
cancelTimers.push(
|
|
163
|
-
arm(
|
|
183
|
+
arm(bound, () => {
|
|
164
184
|
void deps.send(notice("timeout", deps.language()));
|
|
165
185
|
settle({ kind: "cancelled" });
|
|
166
186
|
}),
|
|
@@ -197,6 +217,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
197
217
|
|
|
198
218
|
reset(): void {
|
|
199
219
|
other.clear();
|
|
220
|
+
scopedTimeoutMs = undefined;
|
|
200
221
|
settle({ kind: "cancelled" });
|
|
201
222
|
},
|
|
202
223
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { screenHint } from "../../lib/tui/chrome.ts";
|
|
11
|
+
import { screenFooter, screenHint } from "../../lib/tui/chrome.ts";
|
|
12
12
|
import { clip } from "../../lib/tui/columns.ts";
|
|
13
13
|
import { isCloseKey } from "../../lib/tui/cursor.ts";
|
|
14
14
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
@@ -87,8 +87,7 @@ export class LoginScreenComponent implements Component {
|
|
|
87
87
|
titleRule(theme, target, "wechat · login"),
|
|
88
88
|
"",
|
|
89
89
|
...loginBody(this.options.state(), target),
|
|
90
|
-
"",
|
|
91
|
-
screenHint(theme, target, ["Esc cancel"]),
|
|
90
|
+
...screenFooter(theme, target, screenHint(theme, target, ["Esc cancel"])),
|
|
92
91
|
];
|
|
93
92
|
}
|
|
94
93
|
}
|
|
@@ -22,18 +22,19 @@ export type NoticeKind =
|
|
|
22
22
|
| "budget"
|
|
23
23
|
| "promptWaiting"
|
|
24
24
|
| "timeout"
|
|
25
|
+
| "withdrawn"
|
|
25
26
|
| "failed"
|
|
26
27
|
| "newBusy";
|
|
27
28
|
|
|
28
29
|
const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
29
|
-
ack: { zh: "
|
|
30
|
+
ack: { zh: "收到,正在排队,轮到就回你。", en: "Got it — queued; I'll answer when it's my turn." },
|
|
30
31
|
busy: {
|
|
31
32
|
zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
|
|
32
33
|
en: "Your message is queued behind another conversation; I'll answer as soon as it's free.",
|
|
33
34
|
},
|
|
34
35
|
stillWorking: {
|
|
35
|
-
zh: "
|
|
36
|
-
en: "Still working
|
|
36
|
+
zh: "还在处理这条,发送 /stop 可取消。",
|
|
37
|
+
en: "Still working — send /stop to cancel.",
|
|
37
38
|
},
|
|
38
39
|
overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
|
|
39
40
|
media: {
|
|
@@ -58,10 +59,11 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
58
59
|
en: "You've reached this hour's message limit — please try again later.",
|
|
59
60
|
},
|
|
60
61
|
promptWaiting: {
|
|
61
|
-
zh: "
|
|
62
|
-
en: "Still waiting for your reply —
|
|
62
|
+
zh: "还在等你的回复,回复「取消」可跳过。",
|
|
63
|
+
en: "Still waiting for your reply — reply “cancel” to skip this.",
|
|
63
64
|
},
|
|
64
65
|
timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
|
|
66
|
+
withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
|
|
65
67
|
failed: { zh: "处理失败,请稍后重试。", en: "Something went wrong — please try again." },
|
|
66
68
|
newBusy: {
|
|
67
69
|
zh: "正在处理当前消息,稍后再发送 /new。",
|
|
@@ -20,9 +20,12 @@ export type PromptParse =
|
|
|
20
20
|
| { kind: "cancelled" }
|
|
21
21
|
| { kind: "retry" };
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const SELECT_OTHER_HINT =
|
|
24
24
|
"回复编号或选项文字;其它内容作为自定义回答;回复「取消」或 cancel 跳过。\n" +
|
|
25
25
|
"Reply with a number or the option text; anything else becomes your own answer; reply cancel to dismiss.";
|
|
26
|
+
const SELECT_CHOICE_HINT =
|
|
27
|
+
"回复编号或选项文字;回复「取消」或 cancel 跳过。\n" +
|
|
28
|
+
"Reply with a number or the option text; reply cancel to dismiss.";
|
|
26
29
|
const CONFIRM_HINT = "回复「是」或「否」。\nReply yes or no.";
|
|
27
30
|
const INPUT_HINT = "直接回复内容;回复「取消」或 cancel 跳过。\nReply with the text; reply cancel to skip.";
|
|
28
31
|
const EDITOR_HINT =
|
|
@@ -32,7 +35,7 @@ const EDITOR_HINT =
|
|
|
32
35
|
export const RETRY_NOTICE =
|
|
33
36
|
"没有识别到有效回复,请重新回答。\nSorry, I didn't recognize that reply — please answer again.";
|
|
34
37
|
|
|
35
|
-
const CANCEL_WORDS = new Set(["取消", "cancel", "quit", "exit"]);
|
|
38
|
+
const CANCEL_WORDS = new Set(["取消", "退出", "cancel", "quit", "exit"]);
|
|
36
39
|
const YES_WORDS = new Set(["是", "y", "yes", "1", "确认", "确定", "行", "ok", "好", "对"]);
|
|
37
40
|
const NO_WORDS = new Set(["否", "n", "no", "0", "不", "不是", "不行"]);
|
|
38
41
|
|
|
@@ -50,7 +53,8 @@ function promptBody(request: InteractionRequest): string {
|
|
|
50
53
|
switch (request.kind) {
|
|
51
54
|
case "select": {
|
|
52
55
|
const options = (request.options ?? []).map((label, index) => `${index + 1}. ${label}`);
|
|
53
|
-
|
|
56
|
+
const hasOther = (request.options ?? []).includes(INTERACTION_OTHER_LABEL);
|
|
57
|
+
return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT : SELECT_CHOICE_HINT]);
|
|
54
58
|
}
|
|
55
59
|
case "confirm":
|
|
56
60
|
return lines([request.title, request.message, CONFIRM_HINT]);
|
|
@@ -61,12 +65,23 @@ function promptBody(request: InteractionRequest): string {
|
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Cancel intent, including the phrases a phone user naturally types.
|
|
70
|
+
* Exact words keep the old meaning; the two patterns cover “退出 plan 模式” and
|
|
71
|
+
* “exit plan mode” without treating a sentence such as “exit code” as a cancel.
|
|
72
|
+
*/
|
|
73
|
+
function isCancelIntent(text: string, lower: string): boolean {
|
|
74
|
+
if (CANCEL_WORDS.has(lower)) return true;
|
|
75
|
+
if (/^(退出|离开)\s*(计划|plan)/i.test(text)) return true;
|
|
76
|
+
return /^(exit|leave)\s+(the\s+)?plan\b/i.test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
/** Interpret a WeChat reply to `request`. */
|
|
65
80
|
export function parsePromptAnswer(request: InteractionRequest, raw: string): PromptParse {
|
|
66
81
|
const text = sanitizeInbound(raw);
|
|
67
82
|
const lower = text.toLowerCase();
|
|
68
83
|
|
|
69
|
-
if (
|
|
84
|
+
if (isCancelIntent(text, lower)) {
|
|
70
85
|
return request.kind === "confirm" ? { kind: "confirmed", confirmed: false } : { kind: "cancelled" };
|
|
71
86
|
}
|
|
72
87
|
|