@gavin-hu/my-pi-agent 0.17.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -199,17 +199,16 @@ so every bubble has to carry information.
199
199
 
200
200
  ### The reply contract
201
201
 
202
- One acknowledgement per wait episode, one answer per turn. A message that
203
- arrives before the first turn starts is merged into that turn, so it is answered
204
- 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.
205
204
 
206
205
  | Situation | What the peer gets |
207
206
  |---|---|
208
207
  | 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
209
- | 1st message, peer busy | typing, `收到,正在排队,轮到就回你。` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
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 |
211
- | 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
212
- | 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 |
213
212
 
214
213
  Two consequences worth knowing: an answer can take up to one long-poll cycle
215
214
  (the server holds each `getupdates`; measured ~18 s) before the turn even
@@ -227,9 +226,9 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
227
226
  - Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
228
227
  `exit plan mode` are recognized as cancel intent. In a plan review a dismissal
229
228
  leaves plan mode without approving.
230
- - Hints are bilingual (Chinese and English). A reply that cannot be read
231
- re-sends the prompt with a short “didn’t recognize that” notice instead of
232
- repeating it silently.
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.
233
232
  - A command is not an answer. While a dialog waits the model is blocked on it, so
234
233
  the slash commands are matched first: `/status` names the waiting question and
235
234
  `/stop` stops the turn, where before both were parsed as an attempt to answer
@@ -237,12 +236,9 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
237
236
  so a number, 是/否, or 取消 behaves as above.
238
237
  - An unanswered prompt is bounded: a reminder goes out halfway and the question
239
238
  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.
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.
246
242
  - A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
247
243
  says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
248
244
  timeout line: the peer was not slow, the question left.
@@ -257,25 +253,17 @@ to WeChat. See [Design notes](#design-notes).
257
253
 
258
254
  ## Outbound files
259
255
 
260
- A remote prompt can deliver a local file first. `exit_plan_mode` uses this to
261
- send the plan file itself before the five choices, so WeChat sees the whole plan
262
- (a long one falls back to chunked text if the upload fails). The peer session's
263
- UI context carries the bridge channel, so `askHuman` finds it and posts the file
264
- then the text before the dialogs. Before the choices it also posts a short
265
- digest — the title, the step count, and the first steps — so the review is
266
- answerable from the chat even if the file bubble is never opened, and a file or
267
- digest the bridge cannot deliver is reported on the terminal instead of vanishing.
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.
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.
274
262
  The flow mirrors the
275
263
  reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
276
264
  (`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
277
- `type: 4` file item. Only the plan review uses it today; images, video, and voice
278
- are not sent outbound.
265
+ `type: 4` file item. No current flow uses it; images, video, and voice are not
266
+ sent outbound.
279
267
 
280
268
  ## Configuration
281
269
 
@@ -529,7 +517,6 @@ status, which is what the old start/stop guidance used to do.
529
517
  | `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
530
518
  | `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
531
519
  | `login.ts` | QR login state machine. |
532
- | `qrcode.ts` | QR matrix → Unicode half-block terminal lines; the `qrcode` package is imported on first use, not at registration. |
533
520
  | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
534
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`). |
535
522
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
@@ -246,7 +246,6 @@ 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
250
  async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
252
251
  if (!command.run) return;
@@ -280,27 +279,28 @@ export function createBridge(deps: BridgeDeps): Bridge {
280
279
  }
281
280
 
282
281
  /**
283
- * 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.
284
283
  *
285
284
  * A failure is reported on the terminal once per peer by the deliverer; a silent
286
285
  * failure would leave the peer with only the choices, as if nothing preceded
287
286
  * them.
288
287
  */
289
- async function sendPrompt(text: string, signal?: AbortSignal): Promise<boolean> {
288
+ async function sendPrompt(text: string, signal?: AbortSignal, options?: { force?: boolean }): Promise<boolean> {
290
289
  const current = worker?.current();
291
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;
292
294
  return deliverer.prompt(current, text, signal ?? abort?.signal);
293
295
  }
294
-
295
- /** Upload and send a local file to the peer owning the in-flight turn. */
296
296
  async function sendFileToPeer(filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean> {
297
297
  const current = worker?.current();
298
298
  if (!current) return false;
299
+ await worker?.flush(current.peer);
299
300
  return deliverer.file(current, filePath, fileName, signal ?? abort?.signal);
300
301
  }
301
-
302
302
  const interaction = createWechatInteractionChannel({
303
- send: (text, signal) => sendPrompt(text, signal),
303
+ send: (text, signal, options) => sendPrompt(text, signal, options),
304
304
  sendFile: (path, name, signal) => sendFileToPeer(path, name, signal),
305
305
  activePeer: () => worker?.current()?.peer,
306
306
  language: () => languageOf(worker?.current()?.peer ?? ""),
@@ -618,7 +618,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
618
618
  // not be cancelled by the poll abort that follows.
619
619
  const stopping = abort;
620
620
  abort = undefined;
621
- if (interaction.hasPending()) interaction.cancel();
621
+ if (interaction.hasPending()) await interaction.cancel();
622
622
  stopping?.abort();
623
623
  try {
624
624
  await loopPromise;
@@ -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
 
@@ -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. */
@@ -55,7 +55,9 @@ export interface WechatInteractionChannel extends InteractionChannel {
55
55
  /** The waiting prompt, for the control screen; undefined when none waits. */
56
56
  pendingInfo(): PendingPromptInfo | undefined;
57
57
  /** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
58
- cancel(): void;
58
+ cancel(): Promise<void>;
59
+ /** The language prompts are rendered in. */
60
+ language(): PeerLanguage;
59
61
  /** Drop any pending prompt (turn settled or the bridge stopped). */
60
62
  reset(): void;
61
63
  }
@@ -111,6 +113,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
111
113
  return deps.activePeer() !== undefined;
112
114
  },
113
115
 
116
+ language(): PeerLanguage {
117
+ return deps.language();
118
+ },
119
+
114
120
  hasPending(): boolean {
115
121
  return pending !== undefined;
116
122
  },
@@ -127,21 +133,26 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
127
133
  };
128
134
  },
129
135
 
130
- cancel(): void {
131
- if (!pending) return;
136
+ async cancel(): Promise<void> {
137
+ const current = pending;
138
+ if (!current) return;
132
139
  // The peer saw the prompt; one line says it was withdrawn, which is not the
133
140
  // same as expiry — they were not slow, the question went away. `reset` is the
134
- // silent cleanup path and deliberately does not send this.
135
- void deps.send(notice("withdrawn", deps.language()));
136
- settle({ kind: "cancelled" });
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
+ }
137
148
  },
138
149
 
139
150
  async post(text: string, signal?: AbortSignal): Promise<void> {
140
- await deps.send(text, signal);
151
+ await deps.send(text, signal, { force: true });
141
152
  },
142
153
 
143
154
  async postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean> {
144
- return deps.sendFile(path, name, signal);
155
+ return deps.sendFile(path, name, signal, { force: true });
145
156
  },
146
157
 
147
158
  setPromptTimeout(ms: number): () => void {
@@ -178,15 +189,17 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
178
189
  // An ignored question must not freeze the session: nudge once, then
179
190
  // cancel so the model gets a clean answer instead of a hang.
180
191
  const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
181
- cancelTimers.push(arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()))));
192
+ cancelTimers.push(
193
+ arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()), undefined, { force: true })),
194
+ );
182
195
  cancelTimers.push(
183
196
  arm(bound, () => {
184
- void deps.send(notice("timeout", deps.language()));
197
+ void deps.send(notice("timeout", deps.language()), undefined, { force: true });
185
198
  settle({ kind: "cancelled" });
186
199
  }),
187
200
  );
188
201
  });
189
- const sent = await deps.send(formatPrompt(request), signal);
202
+ const sent = await deps.send(formatPrompt(request, { language: deps.language() }), signal);
190
203
  if (!sent) settle({ kind: "cancelled" });
191
204
  return await answer;
192
205
  },
@@ -197,7 +210,9 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
197
210
  const parsed = parsePromptAnswer(current.request, text);
198
211
  switch (parsed.kind) {
199
212
  case "retry":
200
- 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
+ });
201
216
  return true;
202
217
  case "other":
203
218
  other.set(peer, parsed.text);
@@ -27,14 +27,14 @@ export type NoticeKind =
27
27
  | "newBusy";
28
28
 
29
29
  const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
30
- ack: { zh: "收到,正在排队,轮到就回你。", en: "Got it — queued; I'll answer when it's my turn." },
30
+ ack: { zh: "收到,排队中,轮到就回。", en: "Queued — I'll answer next." },
31
31
  busy: {
32
- zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
33
- 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.",
34
34
  },
35
35
  stillWorking: {
36
- zh: "还在处理这条,发送 /stop 可取消。",
37
- en: "Still working — send /stop to cancel.",
36
+ zh: "还在处理,/stop 可取消。",
37
+ en: "Still working — /stop cancels.",
38
38
  },
39
39
  overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
40
40
  media: {
@@ -59,8 +59,8 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
59
59
  en: "You've reached this hour's message limit — please try again later.",
60
60
  },
61
61
  promptWaiting: {
62
- zh: "还在等你的回复,回复「取消」可跳过。",
63
- en: "Still waiting for your reply — reply “cancel” to skip this.",
62
+ zh: "等你回复;回复「取消」跳过。",
63
+ en: "Waiting for your reply — reply cancel to skip.",
64
64
  },
65
65
  timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
66
66
  withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
@@ -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.