@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -2,11 +2,11 @@
2
2
  * The WeChat bridge: one connection per account, one session per peer.
3
3
  *
4
4
  * This is the composition root. The poll loop, outbound delivery, the typing and
5
- * wait notices, admission (dedupe/cap/budget), and inbound media each live in
6
- * their own module; the bridge wires them to the live client, account, cursor,
7
- * and state, and owns the connection lifecycle and the status snapshot. Each
8
- * admitted message is handed to the sender's own session through the peer
9
- * worker, so a WeChat turn never runs in the terminal's session.
5
+ * wait notices, admission (dedupe/cap/budget), inbound media, and the inbound
6
+ * path itself each live in their own module; the bridge wires them to the live
7
+ * client, account, cursor, and state, and owns the connection lifecycle and the
8
+ * status snapshot. Each admitted message is handed to the sender's own session
9
+ * through the peer worker, so a WeChat turn never runs in the terminal's session.
10
10
  *
11
11
  * The peer only has the chat window, so the bridge carries the signals the phone
12
12
  * needs: typing starts as soon as a message is picked up, a message waiting on
@@ -22,46 +22,24 @@ import { createDeliverer, type MessageTarget } from "./transport/deliver.ts";
22
22
  import { createInboundAdmission } from "./transport/inbound.ts";
23
23
  import { createPollLoop, defaultPollTimeout } from "./transport/poll.ts";
24
24
  import { messageOf } from "./errors.ts";
25
- import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./text/format.ts";
25
+ import { peerKey, sanitizeInbound, type PeerLanguage } from "./text/format.ts";
26
26
  import { notice } from "./text/notices.ts";
27
27
  import { createPresence } from "./peer/presence.ts";
28
+ import { createForgetToken, createIntake, createLanguageOf, type Counter } from "./intake.ts";
29
+
30
+ // The admission predicates, re-exported: they are the bridge's contract, and a caller
31
+ // should not have to know which module inside it answers them.
32
+ export { isAllowedPeer, isInboundMessage } from "./intake.ts";
28
33
  import { createPeerChannels, type PeerChannels } from "./dialog/channels.ts";
29
34
  import type { PendingPromptInfo, WechatInteractionChannel } from "./dialog/interaction.ts";
30
35
  import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
31
36
  import { createPeerWorker, type PeerState, type PeerWorker } from "./peer/peer-worker.ts";
32
37
  import type { PeerSession } from "./peer/peer-session.ts";
33
- import { emptyState, findPeer, ownerTarget, recordInbound, rememberMessage, seenMessage } from "./state/state.ts";
34
- import {
35
- expandSnippet,
36
- findSlashCommand,
37
- parseSlash,
38
- slashControls,
39
- SLASH_COMMANDS,
40
- type SlashCommand,
41
- type SlashContext,
42
- } from "./commands/slash.ts";
43
- import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
38
+ import { emptyState, ownerTarget } from "./state/state.ts";
39
+ import { SLASH_COMMANDS, type SlashCommand } from "./commands/slash.ts";
40
+ import type { BotCredentials, WechatState } from "./types.ts";
44
41
  import type { WechatConfig } from "./config.ts";
45
42
 
46
- /** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
47
- const MAX_QUEUE = 8;
48
-
49
- /**
50
- * Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
51
- * form is accepted (with or without `@im.wechat`), so a label copied from the
52
- * control screen is not silently refused.
53
- */
54
- export function isAllowedPeer(peer: string, owner: string | undefined, allowedPeers: string[]): boolean {
55
- const key = peerKey(peer);
56
- if (owner !== undefined && peerKey(owner) === key) return true;
57
- return allowedPeers.some((allowed) => peerKey(allowed) === key);
58
- }
59
-
60
- /** True when a message is inbound from a human (not the bot's own echo). */
61
- export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
62
- return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
63
- }
64
-
65
43
  /** The bridge's connection state, published to the UI for the status chip. */
66
44
  export type BridgeState = "stopped" | "running" | "expired" | "paused";
67
45
 
@@ -181,7 +159,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
181
159
  let client: WechatClient | undefined;
182
160
  let account: BotCredentials | undefined;
183
161
  let state: WechatState = { cursor: "", peers: {} };
184
- let refused = 0;
162
+ /** Senders refused since the connection opened; the inbound path bumps it. */
163
+ const refused: Counter = { value: 0 };
185
164
  /** Labels of refused senders, so the operator can allowlist one by name. */
186
165
  const refusedPeers = new Set<string>();
187
166
  /** The language each peer last wrote in, so a media-only message can answer. */
@@ -189,6 +168,9 @@ export function createBridge(deps: BridgeDeps): Bridge {
189
168
  /** Peers already told that the queue is full, until it drains. */
190
169
  const overflow = new Set<string>();
191
170
  const slashCommands = deps.slashCommands ?? SLASH_COMMANDS;
171
+ /** The peer's language memory: written here when a turn settles, read on every message. */
172
+ const languageOf = createLanguageOf(languages);
173
+ const forgetToken = createForgetToken({ state: () => state, saveState: deps.saveState });
192
174
 
193
175
  /** One worker per open connection: it owns the queues and the peer sessions. */
194
176
  let worker: PeerWorker | undefined;
@@ -214,14 +196,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
214
196
  notify("WeChat session expired. Run /wechat login to reconnect.", "warning");
215
197
  }
216
198
 
217
- /** Drop a peer's cached reply token, so the next inbound message replaces it. */
218
- function forgetToken(peer: string): void {
219
- const entry = findPeer(state, peer);
220
- if (!entry?.lastContextToken) return;
221
- delete entry.lastContextToken;
222
- deps.saveState(state);
223
- }
224
-
225
199
  /** Send one bubble to a peer and report whether it was delivered. */
226
200
  async function reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean> {
227
201
  return deliverer.reply(target, text, signal ?? abort?.signal);
@@ -244,35 +218,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
244
218
  lastSendError = undefined;
245
219
  },
246
220
  });
247
- /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
248
- async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
249
- if (!command.run) return;
250
- const ctx: SlashContext = {
251
- args,
252
- peer: target.peer,
253
- reply: async (text) => {
254
- await reply(target, text);
255
- },
256
- activeTurn: worker?.state(target.peer).status === "running",
257
- language: languageOf(target.peer),
258
- session: slashControls(target.peer, {
259
- state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
260
- prompt: (peer) => channels.pendingFor(peer)?.title,
261
- reset: (peer) => {
262
- worker?.reset(peer);
263
- },
264
- stop: (peer) => worker?.abort(peer) ?? false,
265
- history: (peer, count) => deps.readHistory(peer, count),
266
- }),
267
- };
268
- try {
269
- await command.run(ctx);
270
- } catch (error) {
271
- notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
272
- await reply(target, `Command /${command.name} failed.`);
273
- }
274
- }
275
-
276
221
  /**
277
222
  * Every peer's dialogs, one channel each.
278
223
  *
@@ -298,15 +243,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
298
243
  armTimer: deps.armTimer,
299
244
  });
300
245
 
301
- /** The peer's language: from this text, else the last one they wrote. */
302
- function languageOf(peer: string, text?: string): PeerLanguage {
303
- // A Chinese message is evidence of Chinese. Its absence is not evidence of
304
- // English, or a short reply ("ok", "1") would flip an established conversation
305
- // back to English; English is the fallback, so it needs no record of its own.
306
- if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
307
- return languages.get(peer) ?? "en";
308
- }
309
-
310
246
  /** Typing, acknowledgement, and the slow/busy lines; one timer registry each. */
311
247
  const presence = createPresence({
312
248
  typingIndicator: () => deps.config.typingIndicator,
@@ -339,92 +275,27 @@ export function createBridge(deps: BridgeDeps): Bridge {
339
275
  const budget = createTurnBudget(deps.config.maxTurnsPerHour, deps.now);
340
276
  const admission = createAdmissionQueue({ admit: inbound.admit, notify });
341
277
 
342
- function handleMessage(message: WeixinMessage): void {
343
- if (!isInboundMessage(message)) return;
344
- const peer = message.from_user_id;
345
- const { text: rawText, media, unsupported } = parseInbound(message.item_list);
346
- const owner = state.ownerId ?? account?.ilinkUserId;
347
- // Admission comes first: a refused sender never reaches the state file, a
348
- // pending prompt, or the queue.
349
- if (!isAllowedPeer(peer, owner, deps.config.allowedPeers)) {
350
- refused += 1;
351
- const label = peerLabel(peer);
352
- // Name a new sender once, so the operator can allowlist it from the
353
- // terminal; the cap keeps a spam run from filling the list.
354
- if (refusedPeers.size < 5 && !refusedPeers.has(label)) {
355
- refusedPeers.add(label);
356
- notify(`WeChat refused a message from ${label}. Add it to allowedPeers to admit.`, "warning");
357
- }
358
- return;
359
- }
360
- // A server replay of an already handled message must not answer twice. The
361
- // id is persisted, so a reload or reconnect cannot resurrect it.
362
- const messageId = message.message_id;
363
- if (typeof messageId === "number" && messageId !== 0 && seenMessage(state, peer, messageId)) return;
364
-
365
- let text = rawText;
366
- const cap = deps.config.maxInboundChars;
367
- if (text.length > cap) {
368
- const total = text.length;
369
- text = `${text.slice(0, cap)}\n[WeChat message truncated: showing ${cap} of ${total} characters]`;
370
- notify(`WeChat message truncated to ${cap} characters.`, "warning");
371
- }
372
-
373
- const now = deps.now();
374
- recordInbound(state, peer, message.context_token, now);
375
- if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
376
- deps.saveState(state);
377
-
378
- const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
379
- // A command is not an answer: while a dialog waits the model is blocked on it,
380
- // so /stop and /status must reach their handlers or the peer's only way out is
381
- // the prompt timing out. A message with media is never a command, and an
382
- // unknown command falls through to the model as ordinary text.
383
- const slash = media.length === 0 ? parseSlash(text) : undefined;
384
- const command = slash ? findSlashCommand(slashCommands, slash.name) : undefined;
385
- if (command && slash) {
386
- if (command.ownerOnly && peerKey(peer) !== peerKey(owner ?? "")) {
387
- refused += 1;
388
- return;
389
- }
390
- if (command.kind === "snippet") {
391
- text = expandSnippet(command, slash.args);
392
- } else {
393
- void runSlashAction(command, target, slash.args);
394
- return;
395
- }
396
- }
397
- // A reply to a pending prompt is consumed here, before queueing, so it never
398
- // starts a turn of its own. A duplicate answer was stopped by the id check.
399
- if (channels.handleInbound(peer, text)) return;
400
- const language = languageOf(peer, text);
401
- const item: InboundItem = { ...target, text, media, language };
402
- if (!text && media.length === 0) {
403
- // Nothing to read. A voice note without a transcript or a video is
404
- // answered, so the peer knows it did not reach the agent.
405
- if (unsupported > 0) void reply(item, notice("unsupported", language));
406
- return;
407
- }
408
- // A slot is reserved here, before the download, so a burst of media cannot
409
- // all pass the check and then enqueue together.
410
- if (!budget.allow(peer)) {
411
- void reply(item, notice("budget", language));
412
- return;
413
- }
414
- const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
415
- if (busy >= MAX_QUEUE) {
416
- // Refuse rather than grow without bound; one notice per episode, not per
417
- // dropped message.
418
- if (!overflow.has(peer)) {
419
- overflow.add(peer);
420
- void reply(item, notice("overflow", language));
421
- }
422
- return;
423
- }
424
- presence.keepTyping(peer, item.contextToken);
425
- presence.armAck(item);
426
- admission.enqueue(item);
427
- }
278
+ /** The inbound path: admission, commands, and the hand-off to a peer's session. */
279
+ const intake = createIntake({
280
+ state: () => state,
281
+ saveState: deps.saveState,
282
+ account: () => account,
283
+ config: deps.config,
284
+ now: deps.now,
285
+ notify,
286
+ worker: () => worker,
287
+ channels,
288
+ presence,
289
+ budget,
290
+ admission,
291
+ languageOf,
292
+ reply: (target, text) => reply(target, text),
293
+ readHistory: deps.readHistory,
294
+ refused,
295
+ refusedPeers,
296
+ overflow,
297
+ slashCommands,
298
+ });
428
299
 
429
300
  /** The long-poll loop; it reads the live client, cursor, and state. */
430
301
  const poll = createPollLoop({
@@ -440,7 +311,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
440
311
  notify,
441
312
  onExpired: handleExpired,
442
313
  onMessages: (messages) => {
443
- for (const message of messages) handleMessage(message);
314
+ for (const message of messages) intake.handleMessage(message);
444
315
  },
445
316
  refreshLock: deps.refreshLock,
446
317
  isRunning: () => running,
@@ -461,7 +332,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
461
332
  function resetEphemeral(): void {
462
333
  languages.clear();
463
334
  overflow.clear();
464
- refused = 0;
335
+ refused.value = 0;
465
336
  refusedPeers.clear();
466
337
  channels.reset();
467
338
  deliverer.reset();
@@ -665,7 +536,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
665
536
  peerCount: Object.keys(state.peers).length,
666
537
  queued: states.reduce((total, entry) => total + entry.state.queued, 0),
667
538
  running: states.some((entry) => entry.state.status === "running"),
668
- refused,
539
+ refused: refused.value,
669
540
  refusedPeers: [...refusedPeers],
670
541
  mediaDropped: inbound.dropped(),
671
542
  promptPending: channels.hasPending(),
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Inbound messages: admission, the slash commands a message may carry, and the hand-off to
3
+ * the sender's own session.
4
+ *
5
+ * This is the path a message takes from the poll loop to a turn, and it is the one part of
6
+ * the bridge that is a *sequence of decisions* rather than wiring: who is refused, what is
7
+ * a replay, what is a command, what is an answer to a pending prompt, and what is queued or
8
+ * turned away. The bridge keeps the connection lifecycle and the status snapshot; it hands
9
+ * this module the live state it reads, and nothing here touches the client or the lock.
10
+ *
11
+ * Every dependency whose value changes across a connection is a function, because a
12
+ * reconnect replaces the account, the state, and the worker rather than mutating them.
13
+ */
14
+
15
+ import type { AdmissionQueue, TurnBudget } from "./peer/admission.ts";
16
+ import type { PeerWorker } from "./peer/peer-worker.ts";
17
+ import type { Presence } from "./peer/presence.ts";
18
+ import type { PeerChannels } from "./dialog/channels.ts";
19
+ import { messageOf } from "./errors.ts";
20
+ import { findPeer, recordInbound, rememberMessage, seenMessage } from "./state/state.ts";
21
+ import { parseInbound, peerKey, peerLabel, peerLanguage, type PeerLanguage } from "./text/format.ts";
22
+ import { notice } from "./text/notices.ts";
23
+ import {
24
+ expandSnippet,
25
+ findSlashCommand,
26
+ parseSlash,
27
+ slashControls,
28
+ type SlashCommand,
29
+ type SlashContext,
30
+ } from "./commands/slash.ts";
31
+ import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
32
+ import type { MessageTarget } from "./transport/deliver.ts";
33
+ import type { WechatConfig } from "./config.ts";
34
+
35
+ /**
36
+ * Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
37
+ * form is accepted (with or without `@im.wechat`), so a label copied from the
38
+ * control screen is not silently refused.
39
+ */
40
+ export function isAllowedPeer(peer: string, owner: string | undefined, allowedPeers: string[]): boolean {
41
+ const key = peerKey(peer);
42
+ if (owner !== undefined && peerKey(owner) === key) return true;
43
+ return allowedPeers.some((allowed) => peerKey(allowed) === key);
44
+ }
45
+
46
+ /** True when a message is inbound from a human (not the bot's own echo). */
47
+ export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
48
+ return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
49
+ }
50
+
51
+ /** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
52
+ export const MAX_QUEUE = 8;
53
+
54
+ /** Senders refused since the connection opened, capped so a spam run cannot fill the list. */
55
+ const REFUSED_LABEL_CAP = 5;
56
+
57
+ /**
58
+ * The peer's language: from this text, else the last one they wrote.
59
+ *
60
+ * A Chinese message is evidence of Chinese. Its absence is not evidence of English, or a
61
+ * short reply ("ok", "1") would flip an established conversation back; English is the
62
+ * fallback, so it needs no record of its own.
63
+ */
64
+ export function createLanguageOf(languages: Map<string, PeerLanguage>): (peer: string, text?: string) => PeerLanguage {
65
+ return (peer, text) => {
66
+ if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
67
+ return languages.get(peer) ?? "en";
68
+ };
69
+ }
70
+
71
+ /** Drop a peer's cached reply token, so the next inbound message replaces it. */
72
+ export function createForgetToken(deps: {
73
+ state: () => WechatState;
74
+ saveState: (state: WechatState) => void;
75
+ }): (peer: string) => void {
76
+ return (peer) => {
77
+ const state = deps.state();
78
+ const entry = findPeer(state, peer);
79
+ if (!entry?.lastContextToken) return;
80
+ delete entry.lastContextToken;
81
+ deps.saveState(state);
82
+ };
83
+ }
84
+
85
+ /** A counter two modules share: this one bumps it, the status snapshot reads it. */
86
+ export interface Counter {
87
+ value: number;
88
+ }
89
+
90
+ /** Everything the inbound path reads, as the bridge hands it in. */
91
+ export interface IntakeDeps {
92
+ /** The live state: replaced when a re-login changes the owner. */
93
+ state: () => WechatState;
94
+ saveState: (state: WechatState) => void;
95
+ /** The live account, for the owner a message is compared against. */
96
+ account: () => BotCredentials | undefined;
97
+ config: WechatConfig;
98
+ now: () => number;
99
+ notify: (message: string, kind?: "info" | "warning" | "error") => void;
100
+ /** The live worker: a connect or disconnect replaces it. */
101
+ worker: () => PeerWorker | undefined;
102
+ channels: PeerChannels;
103
+ presence: Presence;
104
+ budget: TurnBudget;
105
+ admission: AdmissionQueue;
106
+ languageOf: (peer: string, text?: string) => PeerLanguage;
107
+ /** Send one bubble to a peer for the bridge's own notices. */
108
+ reply: (target: MessageTarget, text: string) => Promise<boolean>;
109
+ readHistory: (peer: string, count: number) => Promise<string>;
110
+ /** Senders refused since the connection opened, and their labels. */
111
+ refused: Counter;
112
+ refusedPeers: Set<string>;
113
+ /** Peers already told the queue is full, until it drains. */
114
+ overflow: Set<string>;
115
+ slashCommands: readonly SlashCommand[];
116
+ }
117
+
118
+ /**
119
+ * The inbound path: one function per message, with the command runner and the language
120
+ * lookup it needs beside it.
121
+ */
122
+ export function createIntake(deps: IntakeDeps): { handleMessage: (message: WeixinMessage) => void } {
123
+ const { config, channels, presence, budget, admission, languageOf, notify } = deps;
124
+
125
+ /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
126
+ async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
127
+ if (!command.run) return;
128
+ const worker = deps.worker();
129
+ const ctx: SlashContext = {
130
+ args,
131
+ peer: target.peer,
132
+ reply: async (text) => {
133
+ await deps.reply(target, text);
134
+ },
135
+ activeTurn: worker?.state(target.peer).status === "running",
136
+ language: languageOf(target.peer),
137
+ session: slashControls(target.peer, {
138
+ state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
139
+ prompt: (peer) => channels.pendingFor(peer)?.title,
140
+ reset: (peer) => {
141
+ worker?.reset(peer);
142
+ },
143
+ stop: (peer) => worker?.abort(peer) ?? false,
144
+ history: (peer, count) => deps.readHistory(peer, count),
145
+ }),
146
+ };
147
+ try {
148
+ await command.run(ctx);
149
+ } catch (error) {
150
+ notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
151
+ await deps.reply(target, `Command /${command.name} failed.`);
152
+ }
153
+ }
154
+
155
+ function handleMessage(message: WeixinMessage): void {
156
+ if (!isInboundMessage(message)) return;
157
+ const state = deps.state();
158
+ const worker = deps.worker();
159
+ const peer = message.from_user_id;
160
+ const { text: rawText, media, unsupported } = parseInbound(message.item_list);
161
+ const owner = state.ownerId ?? deps.account()?.ilinkUserId;
162
+ // Admission comes first: a refused sender never reaches the state file, a
163
+ // pending prompt, or the queue.
164
+ if (!isAllowedPeer(peer, owner, config.allowedPeers)) {
165
+ deps.refused.value += 1;
166
+ const label = peerLabel(peer);
167
+ // Name a new sender once, so the operator can allowlist it from the
168
+ // terminal; the cap keeps a spam run from filling the list.
169
+ if (deps.refusedPeers.size < REFUSED_LABEL_CAP && !deps.refusedPeers.has(label)) {
170
+ deps.refusedPeers.add(label);
171
+ notify(`WeChat refused a message from ${label}. Add it to allowedPeers to admit.`, "warning");
172
+ }
173
+ return;
174
+ }
175
+ // A server replay of an already handled message must not answer twice. The
176
+ // id is persisted, so a reload or reconnect cannot resurrect it.
177
+ const messageId = message.message_id;
178
+ if (typeof messageId === "number" && messageId !== 0 && seenMessage(state, peer, messageId)) return;
179
+
180
+ let text = rawText;
181
+ const cap = config.maxInboundChars;
182
+ if (text.length > cap) {
183
+ const total = text.length;
184
+ text = `${text.slice(0, cap)}\n[WeChat message truncated: showing ${cap} of ${total} characters]`;
185
+ notify(`WeChat message truncated to ${cap} characters.`, "warning");
186
+ }
187
+
188
+ const now = deps.now();
189
+ recordInbound(state, peer, message.context_token, now);
190
+ if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
191
+ deps.saveState(state);
192
+
193
+ const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
194
+ // A command is not an answer: while a dialog waits the model is blocked on it,
195
+ // so /stop and /status must reach their handlers or the peer's only way out is
196
+ // the prompt timing out. A message with media is never a command, and an
197
+ // unknown command falls through to the model as ordinary text.
198
+ const slash = media.length === 0 ? parseSlash(text) : undefined;
199
+ const command = slash ? findSlashCommand(deps.slashCommands, slash.name) : undefined;
200
+ if (command && slash) {
201
+ if (command.ownerOnly && peerKey(peer) !== peerKey(owner ?? "")) {
202
+ deps.refused.value += 1;
203
+ return;
204
+ }
205
+ if (command.kind === "snippet") {
206
+ text = expandSnippet(command, slash.args);
207
+ } else {
208
+ void runSlashAction(command, target, slash.args);
209
+ return;
210
+ }
211
+ }
212
+ // A reply to a pending prompt is consumed here, before queueing, so it never
213
+ // starts a turn of its own. A duplicate answer was stopped by the id check.
214
+ if (channels.handleInbound(peer, text)) return;
215
+ const language = languageOf(peer, text);
216
+ const item: InboundItem = { ...target, text, media, language };
217
+ if (!text && media.length === 0) {
218
+ // Nothing to read. A voice note without a transcript or a video is
219
+ // answered, so the peer knows it did not reach the agent.
220
+ if (unsupported > 0) void deps.reply(item, notice("unsupported", language));
221
+ return;
222
+ }
223
+ // A slot is reserved here, before the download, so a burst of media cannot
224
+ // all pass the check and then enqueue together.
225
+ if (!budget.allow(peer)) {
226
+ void deps.reply(item, notice("budget", language));
227
+ return;
228
+ }
229
+ const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
230
+ if (busy >= MAX_QUEUE) {
231
+ // Refuse rather than grow without bound; one notice per episode, not per
232
+ // dropped message.
233
+ if (!deps.overflow.has(peer)) {
234
+ deps.overflow.add(peer);
235
+ void deps.reply(item, notice("overflow", language));
236
+ }
237
+ return;
238
+ }
239
+ presence.keepTyping(peer, item.contextToken);
240
+ presence.armAck(item);
241
+ admission.enqueue(item);
242
+ }
243
+
244
+ return { handleMessage };
245
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { messageOf } from "../../lib/errors.ts";
19
+ import { planModeRefusal } from "../../lib/plan-entry.ts";
19
20
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
20
21
  import { withRailsSuppressed } from "../../lib/rails.ts";
21
22
  import {
@@ -24,7 +25,7 @@ import {
24
25
  formatWorktreeStatus,
25
26
  isWorktreeCancelled,
26
27
  pruneWorktrees,
27
- worktreeLabel,
28
+ enteredWorktree,
28
29
  worktreeSnapshot,
29
30
  } from "./lifecycle.ts";
30
31
  import { type WorktreeAction, WorktreeListComponent } from "./screen.ts";
@@ -46,36 +47,42 @@ const USAGE = "Usage: /worktree [enter [name]|exit|prune]";
46
47
  /** Refuse a lifecycle change while plan mode is read-only, as `/check` does. */
47
48
  function refuseInPlanMode(ctx: ExtensionContext): boolean {
48
49
  if (!isPlanModeEnabled(ctx.sessionManager.getBranch())) return false;
49
- ctx.ui.notify("Plan mode is read-only. Approve the plan before changing worktrees.", "warning");
50
+ ctx.ui.notify(planModeRefusal("changing worktrees"), "warning");
50
51
  return true;
51
52
  }
52
53
 
53
- async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
54
- if (refuseInPlanMode(ctx)) return;
54
+ /**
55
+ * Run one lifecycle verb and report it the way a command does.
56
+ *
57
+ * Enter and exit differ in what they call and in the sentence a success prints, never in
58
+ * how a refusal is reported: a cancellation is the choice the user just made and reads as
59
+ * info, and everything else is a failure that names the verb that failed.
60
+ */
61
+ async function reportLifecycle(ctx: ExtensionContext, verb: string, run: () => Promise<string>): Promise<void> {
55
62
  try {
56
- const { state } = await enterWorktree(pi, ctx, { name });
57
- ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}\n${state.path}`, "info");
63
+ ctx.ui.notify(await run(), "info");
58
64
  } catch (error) {
59
65
  if (isWorktreeCancelled(error)) {
60
66
  ctx.ui.notify(messageOf(error), "info");
61
67
  return;
62
68
  }
63
- ctx.ui.notify(`Enter failed: ${messageOf(error)}`, "error");
69
+ ctx.ui.notify(`${verb} failed: ${messageOf(error)}`, "error");
64
70
  }
65
71
  }
66
72
 
73
+ async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
74
+ if (refuseInPlanMode(ctx)) return;
75
+ // The path on its own line: a command's answer is read without a tool result beside it,
76
+ // and "which directory am I in now" is the question entering answers.
77
+ await reportLifecycle(ctx, "Enter", async () => {
78
+ const { state } = await enterWorktree(pi, ctx, { name });
79
+ return `${enteredWorktree(state)}\n${state.path}`;
80
+ });
81
+ }
82
+
67
83
  async function runExit(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
68
84
  if (refuseInPlanMode(ctx)) return;
69
- try {
70
- const result = await exitWorktree(pi, ctx, {});
71
- ctx.ui.notify(result.output, "info");
72
- } catch (error) {
73
- if (isWorktreeCancelled(error)) {
74
- ctx.ui.notify(messageOf(error), "info");
75
- return;
76
- }
77
- ctx.ui.notify(`Exit failed: ${messageOf(error)}`, "error");
78
- }
85
+ await reportLifecycle(ctx, "Exit", async () => (await exitWorktree(pi, ctx, {})).output);
79
86
  }
80
87
 
81
88
  async function runPrune(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -36,13 +36,17 @@ export interface ExitOptions {
36
36
  * must not fail because the default branch could not be read.
37
37
  */
38
38
  async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
39
- if (!state.branch) return { branch: null, base: null, ahead: recorded, merged: false };
39
+ // `recorded` is the count `inspectWork` took against the commit the worktree was created
40
+ // at, read while the directory was still there; without that commit there is no fork
41
+ // point, and the count is unknown rather than zero.
42
+ const commits = state.baseCommit ? recorded : undefined;
43
+ if (!state.branch) return { branch: null, base: null, ahead: recorded, commits, merged: false };
40
44
  try {
41
45
  // `origin/HEAD` names the merge target when the repository has a remote; a
42
46
  // local-only repository has none, and the main checkout's own branch is the
43
47
  // branch a merge would actually land on.
44
48
  const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
45
- if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
49
+ if (!base) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
46
50
  // Count against the resolved base branch, not the commit recorded at entry:
47
51
  // a worktree branch rebased onto a newer local branch would otherwise report
48
52
  // every commit the base gained since, as its own.
@@ -55,15 +59,17 @@ async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded:
55
59
  : (await refExists(pi, root, `origin/${base}`))
56
60
  ? `origin/${base}`
57
61
  : null;
58
- if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
62
+ if (!ref) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
63
+ const ahead = await commitsAheadOf(pi, root, state.branch, ref);
59
64
  return {
60
65
  branch: state.branch,
61
66
  base,
62
- ahead: await commitsAheadOf(pi, root, state.branch, ref),
63
- merged: await isBranchMerged(pi, root, state.branch, ref),
67
+ ahead,
68
+ commits,
69
+ merged: ahead === 0 && commits !== 0 && (await isBranchMerged(pi, root, state.branch, ref)),
64
70
  };
65
71
  } catch {
66
- return { branch: state.branch, base: null, ahead: recorded, merged: false };
72
+ return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
67
73
  }
68
74
  }
69
75