@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -4,8 +4,9 @@
4
4
  * Registers the `watch` tool and `/watch` command. Watchers read a file or a
5
5
  * command's merged output, match each new line against a regular expression,
6
6
  * and alert the user; matching lines are reported to the model — and shown as a
7
- * bytefly notice — at the next turn, or wake a turn immediately when the
8
- * watcher opted into `wake`. Watchers are session-only and stopped on shutdown.
7
+ * bytefly notice — at the next turn, or wake a turn as soon as the agent is idle
8
+ * when the watcher opted into `wake`, including right after a running turn
9
+ * settles. Watchers are session-only and stopped on shutdown.
9
10
  *
10
11
  * Load with: pi --extension ./extensions/watch
11
12
  */
@@ -14,8 +15,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { isExtensionEnabled } from "../../lib/env.ts";
15
16
  import { WATCH_MATCH_CHANNEL, type WatchMatchEvent } from "../../lib/events.ts";
16
17
  import { sanitizeLine } from "../../lib/format.ts";
17
- import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
- import { createPendingReporter } from "../../lib/report.ts";
18
+ import { burstText, noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
19
+ import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
19
20
  import { registerCommands } from "./commands.ts";
20
21
  import { clipLabel } from "../../lib/tui/columns.ts";
21
22
  import { formatPendingMatch } from "./format.ts";
@@ -32,10 +33,14 @@ const MAX_CONTEXT_MATCHES = 20;
32
33
  /** Build the model-facing text for the pending-match notice. */
33
34
  export function buildWatchContext(matches: PendingMatch[]): string {
34
35
  const shown = matches.slice(0, MAX_CONTEXT_MATCHES);
35
- const lines = shown.map(formatPendingMatch);
36
36
  const more = matches.length - shown.length;
37
- const suffix = more > 0 ? `\n… ${more} more match${more === 1 ? "" : "es"}` : "";
38
- return `${WATCH_CONTEXT_MARKER}\n${matches.length} watched line${matches.length === 1 ? "" : "s"} matched:\n${lines.join("\n")}${suffix}\nRead more with \`watch matches <id>\`.`;
37
+ return burstText({
38
+ marker: WATCH_CONTEXT_MARKER,
39
+ headline: `${matches.length} watched line${matches.length === 1 ? "" : "s"} matched`,
40
+ lines: shown.map((match) => formatPendingMatch(match)),
41
+ ...(more > 0 ? { more: `… ${more} more match${more === 1 ? "" : "es"}` } : {}),
42
+ hint: "Read more with `watch matches <id>`.",
43
+ });
39
44
  }
40
45
 
41
46
  /** The shared notice for pending matches: model text plus renderer details. */
@@ -64,15 +69,16 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
64
69
  if (!isExtensionEnabled("watch")) return;
65
70
  const runtime = deps.runtime ?? createWatchRuntime();
66
71
 
67
- // Opt-in wake: a `wake` watcher matched while the agent is idle. The reporter
68
- // reports *all* pending matches, not just the waking one, so a non-wake match
69
- // is not silently dropped.
72
+ // Opt-in wake: a `wake` watcher matched. The reporter reports *all* pending
73
+ // matches, not just the waking one, so a non-wake match is not silently
74
+ // dropped, and it retries a burst that arrived mid-turn once the run settles.
70
75
  const reporter = createPendingReporter<PendingMatch>({
71
76
  wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
72
77
  pending: () => runtime.pendingMatches(),
73
78
  markReported: (matches) => runtime.markReported(matches),
74
79
  buildNotice: watchNotice,
75
80
  source: "watch",
81
+ wakeRequested: () => runtime.pendingMatches().some((match) => runtime.get(match.watcherId)?.wake === true),
76
82
  // A wake means the user is away from the terminal, so the alert also leaves
77
83
  // the process. bytefly's notify capability owns delivery; this publishes the
78
84
  // fact and never learns how it is sent. The newest match is the one an alert
@@ -97,41 +103,16 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
97
103
  registerTools(pi, runtime);
98
104
  registerCommands(pi, runtime);
99
105
 
100
- pi.on("session_start", (_event, ctx) => {
101
- reporter.sessionStart();
102
- runtime.load(ctx);
106
+ registerPendingChannel(pi, {
107
+ reporter,
108
+ runtime,
103
109
  // `shutdown` clears this; a reload or session replacement reuses the
104
110
  // runtime, so re-arm it on every session start.
105
- runtime.onMatch = () => reporter.report();
106
- });
107
-
108
- // Tree navigation does not change the watcher table; only repaint.
109
- pi.on("session_tree", (_event, ctx) => {
110
- runtime.setStatus(ctx);
111
- });
112
-
113
- pi.on("agent_start", () => {
114
- reporter.agentStart();
115
- });
116
- pi.on("agent_settled", () => {
117
- reporter.agentSettled();
118
- });
119
-
120
- // Report matches to the model at the start of the next turn.
121
- pi.on("before_agent_start", (_event, ctx) => {
122
- const reported = reporter.beforeAgentStart();
123
- if (!reported) return undefined;
124
- runtime.setStatus(ctx);
125
- return reported;
126
- });
127
-
128
- // Keep stale match notices out of later turns. Only this extension's notices
129
- // are pruned; another source's are left for its own handler.
130
- pi.on("context", (event) => reporter.filterContext(event.messages));
131
-
132
- // `shutdown` detaches the UI controller, which clears both chips.
133
- pi.on("session_shutdown", async () => {
134
- runtime.onMatch = undefined;
135
- await runtime.shutdown();
111
+ onSessionStart: () => {
112
+ runtime.onMatch = () => reporter.report();
113
+ },
114
+ onSessionShutdown: () => {
115
+ runtime.onMatch = undefined;
116
+ },
136
117
  });
137
118
  }
@@ -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
- content: [
87
- {
88
- type: "text" as const,
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
- content: [{ type: "text" as const, text: text.text }],
119
- details: {
120
- action: "matches",
121
- watch: result.watch,
122
- matches: result.lines.join("\n"),
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
- content: [{ type: "text" as const, text: text.text }],
134
- details: {
135
- action: "logs",
136
- watch: result.watch,
137
- logs: result.lines.join("\n"),
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
- content: [
150
- {
151
- type: "text" as const,
152
- text: stopped
153
- ? `Stopped ${watch.id} (${sourceLabel(watch)}).`
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
- content: [
166
- {
167
- type: "text" as const,
168
- text:
169
- result.cleared === 0
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. The peer can also stop
51
- (`/stop`), restart (`/new`), and ask what is happening (`/status`).
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 `收到,正在处理…` / “Got it — working on it.”; a turn still running at 30 seconds adds one `还在处理这条…` / “Still working on this one”. |
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 90 seconds and cancelled at three minutes. |
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,23 +192,23 @@ 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 when their message
195
- contains Chinese). The bridge never sends greetings, read receipts, or progress
196
- chatter: WeChat notifies per message, so every bubble has to carry information.
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
 
200
- One acknowledgement per wait episode, one answer per turn. A message that
201
- arrives before the first turn starts is merged into that turn, so it is answered
202
- inside the answer it is already waiting for — not with a second “working on it”.
202
+ One message, one turn, one answer. A queued message is never merged into
203
+ another prompt; it waits for its own turn. One acknowledgement per wait episode.
203
204
 
204
205
  | Situation | What the peer gets |
205
206
  |---|---|
206
207
  | 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
207
- | 1st message, peer busy | typing, `收到,正在处理…` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
208
- | 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
- | 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
210
- | 3rd…Nth in a burst | same as the queued case; past eight queued, one line asking to wait |
208
+ | 1st message, peer busy | typing, `收到,排队中,轮到就回。` at 4 s, `还在处理前一条,轮到就回。` at 60 s, the answer when the slot frees |
209
+ | 2nd message while a turn runs | its own turn behind the first; the 4 s ack and 60 s busy line; then its own answer |
210
+ | 2nd message already queued | behind the first in FIFO; no merge |
211
+ | 3rd…Nth in a burst | each is its own turn; past eight queued, one line asking to wait |
211
212
 
212
213
  Two consequences worth knowing: an answer can take up to one long-poll cycle
213
214
  (the server holds each `getupdates`; measured ~18 s) before the turn even
@@ -217,17 +218,30 @@ starts, and a reply is delivered when the run settles, not token by token.
217
218
 
218
219
  A dialog raised while a WeChat turn is in flight is forwarded to the sender:
219
220
 
220
- - `select` — a numbered list; reply with a number, the option text, or any other
221
- text for the free-form “Other” entry.
221
+ - `select` — a numbered list; reply with a number or the option text. Free text
222
+ becomes the “Other” answer only when the list carries that entry; the hint says
223
+ so, so a plan review without it does not pretend free text is a choice.
222
224
  - `confirm` — reply 是/否, 确定/不是, or yes/no.
223
225
  - `input` / `editor` — reply with the text.
224
- - Reply 取消 (or cancel) to dismiss.
225
- - Hints are bilingual (Chinese and English). A reply that cannot be read
226
- re-sends the prompt with a short “didn’t recognize that” notice instead of
227
- repeating it silently.
226
+ - Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
227
+ `exit plan mode` are recognized as cancel intent. In a plan review a dismissal
228
+ leaves plan mode without approving.
229
+ - Hints are rendered in the peer’s language, one language per prompt. A reply
230
+ that cannot be read re-sends the prompt with a short “didn’t recognize that”
231
+ notice instead of repeating it silently.
232
+ - A command is not an answer. While a dialog waits the model is blocked on it, so
233
+ the slash commands are matched first: `/status` names the waiting question and
234
+ `/stop` stops the turn, where before both were parsed as an attempt to answer
235
+ and bounced back as “didn’t recognize that”. Any other text is still the answer,
236
+ so a number, 是/否, or 取消 behaves as above.
228
237
  - An unanswered prompt is bounded: a reminder goes out halfway and the question
229
- is cancelled after three minutes with a one-line notice, so an ignored dialog
230
- cannot freeze the session.
238
+ is cancelled at the end of its window with a one-line notice, so an ignored
239
+ dialog cannot freeze the session. A dialog uses three minutes; the plan review
240
+ uses ten. A timed-out plan review is treated like a dismissal: plan mode ends
241
+ without approval, and the plan file remains.
242
+ - A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
243
+ says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
244
+ timeout line: the peer was not slow, the question left.
231
245
 
232
246
  Each peer session is created with its own UI context (`bindExtensions` with
233
247
  `mode: "rpc"`), so its dialogs go to that peer by construction rather than by
@@ -239,21 +253,17 @@ to WeChat. See [Design notes](#design-notes).
239
253
 
240
254
  ## Outbound files
241
255
 
242
- A remote prompt can deliver a local file first. `exit_plan_mode` uses this to
243
- send the plan file itself before the three choices, so WeChat sees the whole plan
244
- (a long one falls back to chunked text if the upload fails). The peer session's
245
- UI context carries the bridge channel, so `askHuman` finds it and posts the file
246
- then the text before the dialogs. Before the choices it also posts a short
247
- digest — the title, the step count, and the first steps — so the review is
248
- answerable from the chat even if the file bubble is never opened, and a file or
249
- digest the bridge cannot deliver is reported on the terminal instead of vanishing.
250
- The choices also include **Send the full plan**, which posts the plan text and
251
- asks again, so the whole plan is reachable without the attachment.
256
+ A remote prompt can deliver a local file first. The plan review uses this: the
257
+ plan file is sent before one compact editor prompt with the title, first steps,
258
+ mode status, and three actions. Reply 2 to keep planning or 3 to exit; any other
259
+ text is a refinement. If the upload fails, the plan text is sent instead. The
260
+ plan file stays on disk. After a Keep decision the user can say “exit plan mode”; the model
261
+ calls `cancel_plan_mode`, which needs no plan path and records no todo or goal.
252
262
  The flow mirrors the
253
263
  reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
254
264
  (`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
255
- `type: 4` file item. Only the plan review uses it today; images, video, and voice
256
- are not sent outbound.
265
+ `type: 4` file item. No current flow uses it; images, video, and voice are not
266
+ sent outbound.
257
267
 
258
268
  ## Configuration
259
269
 
@@ -507,7 +517,6 @@ status, which is what the old start/stop guidance used to do.
507
517
  | `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
508
518
  | `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
509
519
  | `login.ts` | QR login state machine. |
510
- | `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
511
520
  | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
512
521
  | `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
522
  | `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(item: { peer: string; contextToken?: string }, text: string): Promise<boolean> {
229
- return deliverer.reply(item, text, abort?.signal);
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. */
@@ -246,57 +246,61 @@ export function createBridge(deps: BridgeDeps): Bridge {
246
246
  lastSendError = undefined;
247
247
  },
248
248
  });
249
-
250
249
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
251
- async function runSlashAction(command: SlashCommand, item: InboundItem, args: string): Promise<void> {
250
+ async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
252
251
  if (!command.run) return;
253
252
  const ctx: SlashContext = {
254
253
  args,
255
- peer: item.peer,
254
+ peer: target.peer,
256
255
  reply: async (text) => {
257
- await reply(item, text);
256
+ await reply(target, text);
258
257
  },
259
- activeTurn: worker?.state(item.peer).status === "running",
260
- language: languageOf(item.peer),
258
+ activeTurn: worker?.state(target.peer).status === "running",
259
+ language: languageOf(target.peer),
261
260
  session: {
262
261
  newSession: () => {
263
- worker?.reset(item.peer);
262
+ worker?.reset(target.peer);
263
+ },
264
+ stop: () => worker?.abort(target.peer) ?? false,
265
+ status: () => {
266
+ const current = worker?.state(target.peer) ?? { status: "idle" as const, queued: 0 };
267
+ const waiting = interaction.pendingInfo();
268
+ return waiting?.peer === target.peer ? { ...current, prompt: waiting.title } : current;
264
269
  },
265
- stop: () => worker?.abort(item.peer) ?? false,
266
- status: () => worker?.state(item.peer) ?? { status: "idle", queued: 0 },
267
- history: (count) => deps.readHistory(item.peer, count),
270
+ history: (count) => deps.readHistory(target.peer, count),
268
271
  },
269
272
  };
270
273
  try {
271
274
  await command.run(ctx);
272
275
  } catch (error) {
273
276
  notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
274
- await reply(item, `Command /${command.name} failed.`);
277
+ await reply(target, `Command /${command.name} failed.`);
275
278
  }
276
279
  }
277
280
 
278
281
  /**
279
- * Send a dialog preface (or plan digest) to the peer owning the in-flight turn.
282
+ * Send a prompt to the peer owning the in-flight turn.
280
283
  *
281
284
  * A failure is reported on the terminal once per peer by the deliverer; a silent
282
285
  * failure would leave the peer with only the choices, as if nothing preceded
283
286
  * them.
284
287
  */
285
- async function sendPrompt(text: string, signal?: AbortSignal): Promise<boolean> {
288
+ async function sendPrompt(text: string, signal?: AbortSignal, options?: { force?: boolean }): Promise<boolean> {
286
289
  const current = worker?.current();
287
290
  if (!current) return false;
291
+ // The model may have produced text before this prompt; put it on the wire first.
292
+ await worker?.flush(current.peer);
293
+ if (!options?.force && !interaction.hasPending()) return false;
288
294
  return deliverer.prompt(current, text, signal ?? abort?.signal);
289
295
  }
290
-
291
- /** Upload and send a local file to the peer owning the in-flight turn. */
292
296
  async function sendFileToPeer(filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean> {
293
297
  const current = worker?.current();
294
298
  if (!current) return false;
299
+ await worker?.flush(current.peer);
295
300
  return deliverer.file(current, filePath, fileName, signal ?? abort?.signal);
296
301
  }
297
-
298
302
  const interaction = createWechatInteractionChannel({
299
- send: (text, signal) => sendPrompt(text, signal),
303
+ send: (text, signal, options) => sendPrompt(text, signal, options),
300
304
  sendFile: (path, name, signal) => sendFileToPeer(path, name, signal),
301
305
  activePeer: () => worker?.current()?.peer,
302
306
  language: () => languageOf(worker?.current()?.peer ?? ""),
@@ -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
- if (text) {
309
- const language = peerLanguage(text);
310
- languages.set(peer, language);
311
- return language;
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
- // A reply to a pending prompt is consumed here, before queueing, so it never
390
- // starts a turn of its own. A duplicate answer was stopped by the id check.
391
- if (interaction.handleInbound(peer, text)) return;
392
- const language = languageOf(peer, text);
393
- const item: InboundItem = {
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, item, slash.args);
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)) {
@@ -620,7 +618,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
620
618
  // not be cancelled by the poll abort that follows.
621
619
  const stopping = abort;
622
620
  abort = undefined;
623
- if (interaction.hasPending()) interaction.cancel();
621
+ if (interaction.hasPending()) await interaction.cancel();
624
622
  stopping?.abort();
625
623
  try {
626
624
  await loopPromise;
@@ -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
  }
@@ -30,7 +30,7 @@ import { peerLabel, peerKey } from "./format.ts";
30
30
  import { waitForLogin, type LoginOutcome } from "./login.ts";
31
31
  import { defaultSleep } from "./timer.ts";
32
32
  import { LoginScreenComponent, type LoginViewState } from "./login-screen.ts";
33
- import { qrLines } from "./qrcode.ts";
33
+ import { qrLines } from "../../lib/qr.ts";
34
34
  import { statusLine, statusView, StatusScreenComponent, type StatusAction, type StatusView } from "./status-screen.ts";
35
35
  import type { BotCredentials } from "./types.ts";
36
36
 
@@ -154,7 +154,7 @@ function resetMessage(bridge: Bridge, peer: string): VerbOutcome {
154
154
 
155
155
  function cancelPromptMessage(bridge: Bridge): VerbOutcome {
156
156
  if (!bridge.status().promptPending) return { message: "No prompt is waiting.", kind: "info" };
157
- bridge.channel().cancel();
157
+ void bridge.channel().cancel();
158
158
  return { message: "Cancelled the waiting prompt.", kind: "info" };
159
159
  }
160
160
 
@@ -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 () => {