@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
@@ -59,7 +59,7 @@ export interface DeliveryDeps {
59
59
  export interface Deliverer {
60
60
  /** Send a reply to a peer; false when the transport rejected it. */
61
61
  reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
62
- /** Send a dialog preface (or plan digest) to a peer. */
62
+ /** Send a prompt to a peer. */
63
63
  prompt(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
64
64
  /** Upload and send a local file to a peer. */
65
65
  file(target: MessageTarget, filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean>;
@@ -72,6 +72,22 @@ export interface Deliverer {
72
72
  export function createDeliverer(deps: DeliveryDeps): Deliverer {
73
73
  /** Peers already reported as unable to receive, until a send gets through. */
74
74
  const failureNotified = new Set<string>();
75
+ /** Per-peer FIFO: a reply, prompt, or file cannot start before the previous one. */
76
+ const queues = new Map<string, Promise<void>>();
77
+
78
+ function serialize<T>(peer: string, work: () => Promise<T>): Promise<T> {
79
+ const previous = queues.get(peer) ?? Promise.resolve();
80
+ const run = previous.then(work, work);
81
+ const settled = run.then(
82
+ () => undefined,
83
+ () => undefined,
84
+ );
85
+ queues.set(peer, settled);
86
+ void settled.finally(() => {
87
+ if (queues.get(peer) === settled) queues.delete(peer);
88
+ });
89
+ return run;
90
+ }
75
91
 
76
92
  /**
77
93
  * Send `text` in `maxReplyChars` chunks, stopping at the first failed chunk.
@@ -149,51 +165,55 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
149
165
 
150
166
  return {
151
167
  reply(target, text, signal) {
152
- return deliverWithNotice(target, text, "reply", signal);
168
+ return serialize(target.peer, () => deliverWithNotice(target, text, "reply", signal));
153
169
  },
154
170
 
155
171
  prompt(target, text, signal) {
156
- return deliverWithNotice(target, text, "message delivery", signal);
172
+ return serialize(target.peer, () => deliverWithNotice(target, text, "message delivery", signal));
157
173
  },
158
174
 
159
- async file(target, filePath, fileName, signal) {
160
- const client = deps.client();
161
- const account = deps.account();
162
- if (!client || !account) return false;
163
- const sent = await sendLocalFile({
164
- client,
165
- account,
166
- cdnBaseUrl: deps.cdnBaseUrl,
167
- maxBytes: deps.maxMediaBytes,
168
- to: target.peer,
169
- contextToken: target.contextToken,
170
- filePath,
171
- fileName,
172
- signal,
175
+ file(target, filePath, fileName, signal) {
176
+ return serialize(target.peer, async () => {
177
+ const client = deps.client();
178
+ const account = deps.account();
179
+ if (!client || !account) return false;
180
+ const sent = await sendLocalFile({
181
+ client,
182
+ account,
183
+ cdnBaseUrl: deps.cdnBaseUrl,
184
+ maxBytes: deps.maxMediaBytes,
185
+ to: target.peer,
186
+ contextToken: target.contextToken,
187
+ filePath,
188
+ fileName,
189
+ signal,
190
+ });
191
+ // `sendLocalFile` reports only success, and the caller may fall back to text;
192
+ // without this line the operator would never know the attachment was dropped.
193
+ if (!sent && !signal?.aborted) {
194
+ deps.onSendError?.(`file delivery failed: ${fileName}`);
195
+ deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
196
+ }
197
+ if (sent) deps.onSent?.();
198
+ return sent;
173
199
  });
174
- // `sendLocalFile` reports only success, and the caller may fall back to text;
175
- // without this line the operator would never know the attachment was dropped.
176
- if (!sent && !signal?.aborted) {
177
- deps.onSendError?.(`file delivery failed: ${fileName}`);
178
- deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
179
- }
180
- if (sent) deps.onSent?.();
181
- return sent;
182
200
  },
183
201
 
184
- async toOwner(owner, contextToken, text, signal) {
185
- let failure: string | undefined;
186
- const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
187
- deps.forgetToken(owner);
188
- const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
189
- deps.onSendError?.(messageOf(error));
190
- failure = `${messageOf(error)}${partial}`;
202
+ toOwner(owner, contextToken, text, signal) {
203
+ return serialize(owner, async () => {
204
+ let failure: string | undefined;
205
+ const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
206
+ deps.forgetToken(owner);
207
+ const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
208
+ deps.onSendError?.(messageOf(error));
209
+ failure = `${messageOf(error)}${partial}`;
210
+ });
211
+ if (delivery.expired) return { ok: false, error: "WeChat session expired." };
212
+ if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
213
+ if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
214
+ deps.onSent?.();
215
+ return { ok: true };
191
216
  });
192
- if (delivery.expired) return { ok: false, error: "WeChat session expired." };
193
- if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
194
- if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
195
- deps.onSent?.();
196
- return { ok: true };
197
217
  },
198
218
 
199
219
  reset() {
@@ -28,10 +28,10 @@ import { defaultTimer, type ArmTimer } from "./timer.ts";
28
28
  export const PROMPT_TIMEOUT_MS = 180_000;
29
29
 
30
30
  export interface WechatInteractionDeps {
31
- /** Send one text message to the owning peer; resolves false when it fails. */
32
- send: (text: string, signal?: AbortSignal) => Promise<boolean>;
33
- /** Send a local file to the owning peer; resolves false when it fails. */
34
- sendFile: (path: string, name: string, signal?: AbortSignal) => Promise<boolean>;
31
+ /** Send one text message to the owning peer; `force` bypasses the prompt-active gate. */
32
+ send: (text: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
33
+ /** Send a local file to the owning peer; `force` bypasses the prompt-active gate. */
34
+ sendFile: (path: string, name: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
35
35
  /** The peer that owns the in-flight turn, or undefined when there is none. */
36
36
  activePeer: () => string | undefined;
37
37
  /** The language the owning peer writes in, for the timeout notices. */
@@ -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. */
@@ -50,7 +55,9 @@ export interface WechatInteractionChannel extends InteractionChannel {
50
55
  /** The waiting prompt, for the control screen; undefined when none waits. */
51
56
  pendingInfo(): PendingPromptInfo | undefined;
52
57
  /** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
53
- cancel(): void;
58
+ cancel(): Promise<void>;
59
+ /** The language prompts are rendered in. */
60
+ language(): PeerLanguage;
54
61
  /** Drop any pending prompt (turn settled or the bridge stopped). */
55
62
  reset(): void;
56
63
  }
@@ -79,6 +86,11 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
79
86
  const arm = deps.armTimer ?? defaultTimer;
80
87
  const now = deps.now ?? Date.now;
81
88
  let pending: Pending | undefined;
89
+ /**
90
+ * The bound for the prompts asked inside the current `setPromptTimeout` scope,
91
+ * set by a caller whose dialog needs reading time before it can be answered.
92
+ */
93
+ let scopedTimeoutMs: number | undefined;
82
94
  /**
83
95
  * Free text captured from a `select` "Other" reply, per peer: the immediately
84
96
  * following `input` for that peer returns it. Keyed so one peer's free text can
@@ -101,6 +113,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
101
113
  return deps.activePeer() !== undefined;
102
114
  },
103
115
 
116
+ language(): PeerLanguage {
117
+ return deps.language();
118
+ },
119
+
104
120
  hasPending(): boolean {
105
121
  return pending !== undefined;
106
122
  },
@@ -117,20 +133,34 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
117
133
  };
118
134
  },
119
135
 
120
- cancel(): void {
121
- if (!pending) return;
122
- // The peer saw the prompt; one line says it was withdrawn. `reset` is the
123
- // silent cleanup path and deliberately does not send this.
124
- void deps.send(notice("timeout", deps.language()));
125
- settle({ kind: "cancelled" });
136
+ async cancel(): Promise<void> {
137
+ const current = pending;
138
+ if (!current) return;
139
+ // The peer saw the prompt; one line says it was withdrawn, which is not the
140
+ // same as expiry — they were not slow, the question went away. `reset` is the
141
+ // silent cleanup path and deliberately does not send this. Settle after the
142
+ // notice is on the wire, so a caller awaiting the answer knows it was sent.
143
+ try {
144
+ await deps.send(notice("withdrawn", deps.language()), undefined, { force: true });
145
+ } finally {
146
+ if (pending === current) settle({ kind: "cancelled" });
147
+ }
126
148
  },
127
149
 
128
150
  async post(text: string, signal?: AbortSignal): Promise<void> {
129
- await deps.send(text, signal);
151
+ await deps.send(text, signal, { force: true });
130
152
  },
131
153
 
132
154
  async postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean> {
133
- return deps.sendFile(path, name, signal);
155
+ return deps.sendFile(path, name, signal, { force: true });
156
+ },
157
+
158
+ setPromptTimeout(ms: number): () => void {
159
+ const previous = scopedTimeoutMs;
160
+ scopedTimeoutMs = ms;
161
+ return () => {
162
+ scopedTimeoutMs = previous;
163
+ };
134
164
  },
135
165
 
136
166
  async request(request: InteractionRequest, signal?: AbortSignal): Promise<InteractionAnswer> {
@@ -158,15 +188,18 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
158
188
  signal?.addEventListener("abort", onAbort, { once: true });
159
189
  // An ignored question must not freeze the session: nudge once, then
160
190
  // cancel so the model gets a clean answer instead of a hang.
161
- cancelTimers.push(arm(PROMPT_TIMEOUT_MS / 2, () => void deps.send(notice("promptWaiting", deps.language()))));
191
+ const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
192
+ cancelTimers.push(
193
+ arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()), undefined, { force: true })),
194
+ );
162
195
  cancelTimers.push(
163
- arm(PROMPT_TIMEOUT_MS, () => {
164
- void deps.send(notice("timeout", deps.language()));
196
+ arm(bound, () => {
197
+ void deps.send(notice("timeout", deps.language()), undefined, { force: true });
165
198
  settle({ kind: "cancelled" });
166
199
  }),
167
200
  );
168
201
  });
169
- const sent = await deps.send(formatPrompt(request), signal);
202
+ const sent = await deps.send(formatPrompt(request, { language: deps.language() }), signal);
170
203
  if (!sent) settle({ kind: "cancelled" });
171
204
  return await answer;
172
205
  },
@@ -177,7 +210,9 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
177
210
  const parsed = parsePromptAnswer(current.request, text);
178
211
  switch (parsed.kind) {
179
212
  case "retry":
180
- void deps.send(formatPrompt(current.request, { retry: true }));
213
+ void deps.send(formatPrompt(current.request, { retry: true, language: deps.language() }), undefined, {
214
+ force: true,
215
+ });
181
216
  return true;
182
217
  case "other":
183
218
  other.set(peer, parsed.text);
@@ -197,6 +232,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
197
232
 
198
233
  reset(): void {
199
234
  other.clear();
235
+ scopedTimeoutMs = undefined;
200
236
  settle({ kind: "cancelled" });
201
237
  },
202
238
  };
@@ -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: "收到,正在处理…", en: "Got it — working on it." },
30
+ ack: { zh: "收到,排队中,轮到就回。", en: "Queued — I'll answer next." },
30
31
  busy: {
31
- zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
32
- en: "Your message is queued behind another conversation; I'll answer as soon as it's free.",
32
+ zh: "还在处理前一条,轮到就回。",
33
+ en: "Still on the previous message; yours is next.",
33
34
  },
34
35
  stillWorking: {
35
- zh: "还在处理这条,稍等一下…",
36
- en: "Still working on this one — a moment…",
36
+ zh: "还在处理,/stop 可取消。",
37
+ en: "Still working — /stop cancels.",
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: "还在等你的回复,约 1 分钟后超时。",
62
- en: "Still waiting for your reply — this expires in about a minute.",
62
+ zh: "等你回复;回复「取消」跳过。",
63
+ en: "Waiting for your reply — reply cancel to skip.",
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。",
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * - One turn at a time per peer, and by default one turn at a time overall
14
14
  * (`concurrency`), so a forwarded dialog always has a single owning peer.
15
- * - A message that arrives while its peer is already waiting behind a turn is
16
- * merged into the waiting one, so a burst is answered once, in order.
15
+ * - Every message is its own turn, in arrival order. A message that arrives
16
+ * while a turn runs waits for its own turn and answer; nothing is merged.
17
17
  * - A turn's answer is the text it produced. A requested `/stop` says `stopped`
18
18
  * and drops the partial answer, a finished turn with no text says `noText`,
19
19
  * and a thrown turn says `failed` — never silence.
@@ -26,7 +26,7 @@
26
26
  import type { ImageContent } from "@earendil-works/pi-ai";
27
27
  import { messageOf } from "./errors.ts";
28
28
  import type { PeerSession } from "./peer-session.ts";
29
- import { createReplyPipeline } from "./reply-pipeline.ts";
29
+ import { createReplyPipeline, type ReplyPipeline } from "./reply-pipeline.ts";
30
30
  import { defaultTimer, type ArmTimer } from "./timer.ts";
31
31
 
32
32
  /** One admitted, media-resolved inbound message. */
@@ -97,6 +97,8 @@ export interface PeerWorker {
97
97
  reset(peer: string): boolean;
98
98
  /** The turn running right now, if any (one with the default concurrency). */
99
99
  current(): PeerItem | undefined;
100
+ /** Flush buffered assistant text for a running peer before a prompt/file. */
101
+ flush(peer: string): Promise<void>;
100
102
  state(peer: string): PeerState;
101
103
  snapshot(): PeerSnapshot[];
102
104
  /** Drop every session; queued messages are dropped with them. */
@@ -107,8 +109,6 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
107
109
 
108
110
  interface PeerEntry {
109
111
  queue: PeerItem[];
110
- /** Messages merged into the waiting item, so `queued` counts real messages. */
111
- merged: number;
112
112
  running?: PeerItem;
113
113
  /** Monotonic start time of the running turn, for `runningMs`. */
114
114
  startedAt?: number;
@@ -119,26 +119,14 @@ interface PeerEntry {
119
119
  disposeTimer?: () => void;
120
120
  }
121
121
 
122
- /** Merge a waiting item with a newer one from the same peer. */
123
- function mergeItems(first: PeerItem, next: PeerItem): PeerItem {
124
- const images = [...(first.images ?? []), ...(next.images ?? [])];
125
- return {
126
- peer: first.peer,
127
- text: first.text && next.text ? `${first.text}\n${next.text}` : first.text || next.text,
128
- images: images.length > 0 ? images : undefined,
129
- // The first item's prefix still describes an attachment the model never saw.
130
- prefix: first.prefix,
131
- language: next.language,
132
- contextToken: next.contextToken ?? first.contextToken,
133
- };
134
- }
135
-
136
122
  export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
137
123
  const now = deps.now ?? Date.now;
138
124
  const armTimer = deps.armTimer ?? defaultTimer;
139
125
  const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
140
126
  const concurrency = Math.max(1, deps.concurrency ?? 1);
141
127
  const peers = new Map<string, PeerEntry>();
128
+ /** Active reply pipelines, so a dialog can flush buffered text first. */
129
+ const pipelines = new Map<string, ReplyPipeline>();
142
130
  /** Peers whose running turn the peer asked to stop, so it reads as stopped. */
143
131
  const aborted = new Set<string>();
144
132
  /** Peers whose next session must start empty (`/new`). */
@@ -150,7 +138,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
150
138
  function entry(peer: string): PeerEntry {
151
139
  const existing = peers.get(peer);
152
140
  if (existing) return existing;
153
- const created: PeerEntry = { queue: [], merged: 0 };
141
+ const created: PeerEntry = { queue: [] };
154
142
  peers.set(peer, created);
155
143
  return created;
156
144
  }
@@ -168,12 +156,12 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
168
156
  if (target.running) {
169
157
  return {
170
158
  status: "running",
171
- queued: target.queue.length + target.merged,
159
+ queued: target.queue.length,
172
160
  runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
173
161
  ...sessionAge,
174
162
  };
175
163
  }
176
- const queued = target.queue.length + target.merged;
164
+ const queued = target.queue.length;
177
165
  return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
178
166
  }
179
167
 
@@ -251,6 +239,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
251
239
  partChars: deps.partChars,
252
240
  quietMs: deps.quietMs,
253
241
  });
242
+ pipelines.set(item.peer, pipeline);
254
243
  /** The session compacted this turn, so the peer is told its older detail is fuzzy. */
255
244
  let compacted = false;
256
245
  const unsubscribe = session.subscribe((event) => {
@@ -289,6 +278,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
289
278
  }
290
279
  } finally {
291
280
  unsubscribe();
281
+ pipelines.delete(item.peer);
292
282
  }
293
283
  if (reason) await sendPart(deps.fallback(item, reason));
294
284
  if (compacted) await sendPart(deps.fallback(item, "compacted"));
@@ -309,7 +299,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
309
299
  if (!next) return;
310
300
  const [peer, target] = next;
311
301
  const item = target.queue.shift() as PeerItem;
312
- target.merged = 0;
313
302
  target.running = item;
314
303
  target.startedAt = now();
315
304
  running += 1;
@@ -340,16 +329,8 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
340
329
  enqueue(item: PeerItem): void {
341
330
  if (closed) return;
342
331
  const target = entry(item.peer);
343
- const tail = target.queue.at(-1);
344
- // A non-empty queue means this peer's turn has not started yet, so the
345
- // burst is answered once, in send order. The merged count keeps `queued`
346
- // honest so the bridge can cap a flood and the panel can report it.
347
- if (tail) {
348
- target.queue[target.queue.length - 1] = mergeItems(tail, item);
349
- target.merged += 1;
350
- } else {
351
- target.queue.push(item);
352
- }
332
+ // One message, one turn: a queued item is never folded into another.
333
+ target.queue.push(item);
353
334
  wake();
354
335
  },
355
336
 
@@ -368,7 +349,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
368
349
  releaseSession(peer);
369
350
  if (target) {
370
351
  target.queue = [];
371
- target.merged = 0;
372
352
  cancelIdleSweep(target);
373
353
  }
374
354
  return true;
@@ -378,6 +358,10 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
378
358
  return currentItem;
379
359
  },
380
360
 
361
+ async flush(peer: string): Promise<void> {
362
+ await pipelines.get(peer)?.flush();
363
+ },
364
+
381
365
  state(peer: string): PeerState {
382
366
  return stateOf(peer);
383
367
  },
@@ -388,6 +372,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
388
372
 
389
373
  async close(): Promise<void> {
390
374
  closed = true;
375
+ pipelines.clear();
391
376
  const runningAborts = [...peers.values()]
392
377
  .filter((target) => target.session)
393
378
  .map((target) => target.session?.session.abort());
@@ -204,14 +204,13 @@ export function createPresence(deps: PresenceDeps): Presence {
204
204
  * the same thing without spending a bubble.
205
205
  */
206
206
  function armAck(target: PresenceTarget): void {
207
- // A turn for this peer is already running: its own progress notice covers
208
- // this message, so a second acknowledgement would be noise.
209
- if (isRunning(target.peer)) return;
207
+ // A message can be queued behind a running turn; it still gets one
208
+ // acknowledgement after the delay, but not one per message.
210
209
  if (acks.has(target.peer)) return;
211
210
  const last = ackedAt.get(target.peer);
212
211
  if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
213
212
  acks.arm(target.peer, ACK_DELAY_MS, () => {
214
- if (!hasWork(target.peer) || isRunning(target.peer)) return;
213
+ if (!hasWork(target.peer)) return;
215
214
  ackedAt.set(target.peer, deps.now());
216
215
  void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
217
216
  // Still waiting after a minute behind another turn: say why.
@@ -2,15 +2,15 @@
2
2
  * Pure rendering and parsing of remote dialog prompts (no I/O).
3
3
  *
4
4
  * A prompt is rendered to a WeChat message by {@link formatPrompt}; the reply is
5
- * interpreted by {@link parsePromptAnswer}. Hints are bilingual (Chinese and
6
- * English) so either reader can answer, and {@link formatPrompt} can prepend a
7
- * short notice when a reply was not understood. The parser is deliberately total:
5
+ * interpreted by {@link parsePromptAnswer}. Hints are rendered in the peer's
6
+ * language, and {@link formatPrompt} can prepend a short notice when a reply was
7
+ * not understood. The parser is deliberately total:
8
8
  * anything it cannot resolve reports `retry`, and free text on a `select` becomes
9
9
  * the {@link INTERACTION_OTHER_LABEL} option so the caller can collect it once.
10
10
  */
11
11
 
12
12
  import { INTERACTION_OTHER_LABEL, type InteractionRequest } from "../../lib/interaction.ts";
13
- import { sanitizeInbound } from "./format.ts";
13
+ import { sanitizeInbound, type PeerLanguage } from "./format.ts";
14
14
 
15
15
  /** A parsed reply: a resolved answer, the "other" choice, or a retry. */
16
16
  export type PromptParse =
@@ -20,19 +20,32 @@ export type PromptParse =
20
20
  | { kind: "cancelled" }
21
21
  | { kind: "retry" };
22
22
 
23
- const SELECT_HINT =
24
- "回复编号或选项文字;其它内容作为自定义回答;回复「取消」或 cancel 跳过。\n" +
25
- "Reply with a number or the option text; anything else becomes your own answer; reply cancel to dismiss.";
26
- const CONFIRM_HINT = "回复「是」或「否」。\nReply yes or no.";
27
- const INPUT_HINT = "直接回复内容;回复「取消」或 cancel 跳过。\nReply with the text; reply cancel to skip.";
28
- const EDITOR_HINT =
29
- "把内容作为一条消息发回;回复「取消」或 cancel 取消。\nSend the text as one message; reply cancel to cancel.";
23
+ const SELECT_OTHER_HINT: Record<PeerLanguage, string> = {
24
+ zh: "回复编号或选项文字;其它内容作为自定义回答;回复「取消」跳过。",
25
+ en: "Reply with a number or the option text; anything else is your own answer; reply cancel to dismiss.",
26
+ };
27
+ const SELECT_CHOICE_HINT: Record<PeerLanguage, string> = {
28
+ zh: "回复编号或选项文字;回复「取消」跳过。",
29
+ en: "Reply with a number or the option text; reply cancel to dismiss.",
30
+ };
31
+ const CONFIRM_HINT: Record<PeerLanguage, string> = {
32
+ zh: "回复「是」或「否」。",
33
+ en: "Reply yes or no.",
34
+ };
35
+ const INPUT_HINT: Record<PeerLanguage, string> = {
36
+ zh: "直接回复内容;回复「取消」跳过。",
37
+ en: "Reply with the text; reply cancel to skip.",
38
+ };
39
+ const EDITOR_HINT: Record<PeerLanguage, string> = {
40
+ zh: "把内容作为一条消息发回;回复「取消」取消。",
41
+ en: "Send the text as one message; reply cancel to cancel.",
42
+ };
30
43
 
31
44
  /** Sent ahead of a re-prompt when a reply could not be interpreted. */
32
- export const RETRY_NOTICE =
33
- "没有识别到有效回复,请重新回答。\nSorry, I didn't recognize that reply — please answer again.";
45
+ export const RETRY_NOTICE = "Sorry, I didn't recognize that reply — please answer again.";
46
+ const RETRY_ZH = "没有识别到有效回复,请重新回答。";
34
47
 
35
- const CANCEL_WORDS = new Set(["取消", "cancel", "quit", "exit"]);
48
+ const CANCEL_WORDS = new Set(["取消", "退出", "cancel", "quit", "exit"]);
36
49
  const YES_WORDS = new Set(["是", "y", "yes", "1", "确认", "确定", "行", "ok", "好", "对"]);
37
50
  const NO_WORDS = new Set(["否", "n", "no", "0", "不", "不是", "不行"]);
38
51
 
@@ -41,32 +54,49 @@ function lines(parts: Array<string | undefined>): string {
41
54
  }
42
55
 
43
56
  /** Render a dialog prompt as a WeChat message body, optionally after a retry. */
44
- export function formatPrompt(request: InteractionRequest, options: { retry?: boolean } = {}): string {
45
- const body = promptBody(request);
46
- return options.retry ? `${RETRY_NOTICE}\n\n${body}` : body;
57
+ export function formatPrompt(
58
+ request: InteractionRequest,
59
+ options: { retry?: boolean; language?: PeerLanguage } = {},
60
+ ): string {
61
+ const language = options.language ?? "en";
62
+ const body = promptBody(request, language);
63
+ const retry = language === "zh" ? RETRY_ZH : RETRY_NOTICE;
64
+ return options.retry ? `${retry}\n\n${body}` : body;
47
65
  }
48
66
 
49
- function promptBody(request: InteractionRequest): string {
67
+ function promptBody(request: InteractionRequest, language: PeerLanguage): string {
50
68
  switch (request.kind) {
51
69
  case "select": {
52
70
  const options = (request.options ?? []).map((label, index) => `${index + 1}. ${label}`);
53
- return lines([request.title, ...options, SELECT_HINT]);
71
+ const hasOther = (request.options ?? []).includes(INTERACTION_OTHER_LABEL);
72
+ return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT[language] : SELECT_CHOICE_HINT[language]]);
54
73
  }
55
74
  case "confirm":
56
- return lines([request.title, request.message, CONFIRM_HINT]);
75
+ return lines([request.title, request.message, CONFIRM_HINT[language]]);
57
76
  case "input":
58
- return lines([request.title, request.placeholder, INPUT_HINT]);
77
+ return lines([request.title, request.placeholder, INPUT_HINT[language]]);
59
78
  case "editor":
60
- return lines([request.title, request.prefill, EDITOR_HINT]);
79
+ return lines([request.title, request.prefill, EDITOR_HINT[language]]);
61
80
  }
62
81
  }
63
82
 
83
+ /**
84
+ * Cancel intent, including the phrases a phone user naturally types.
85
+ * Exact words keep the old meaning; the two patterns cover “退出 plan 模式” and
86
+ * “exit plan mode” without treating a sentence such as “exit code” as a cancel.
87
+ */
88
+ function isCancelIntent(text: string, lower: string): boolean {
89
+ if (CANCEL_WORDS.has(lower)) return true;
90
+ if (/^(退出|离开)\s*(计划|plan)/i.test(text)) return true;
91
+ return /^(exit|leave)\s+(the\s+)?plan\b/i.test(text);
92
+ }
93
+
64
94
  /** Interpret a WeChat reply to `request`. */
65
95
  export function parsePromptAnswer(request: InteractionRequest, raw: string): PromptParse {
66
96
  const text = sanitizeInbound(raw);
67
97
  const lower = text.toLowerCase();
68
98
 
69
- if (lower !== "" && CANCEL_WORDS.has(lower)) {
99
+ if (isCancelIntent(text, lower)) {
70
100
  return request.kind === "confirm" ? { kind: "confirmed", confirmed: false } : { kind: "cancelled" };
71
101
  }
72
102