@zxh126/pi-qqbot 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,13 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
11
11
  | Feature | Description |
12
12
  |---|---|
13
13
  | **Chat-driven agent** | QQ private messages are fed into the current pi session; the assistant's answer is sent back to QQ (long replies are split automatically). |
14
+ | **Message queue** | While a task is running, further QQ messages are **queued** instead of rejected; each one starts automatically as soon as the current run settles. `/cancel` is the exception — it acts immediately and clears the queue. |
14
15
  | **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
15
16
  | **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
16
17
  | **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
17
- | **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls and the half-written answer are all cleaned up. |
18
+ | **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls, the half-written answer and the pending queue are all cleaned up. |
18
19
  | **Slash-command routing** | QQ messages starting with `/` are routed: pi extension commands, prompt templates and `/skill:name` are dispatched to pi; a set of pi built-ins (`/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`, `/quit`) is taken over; host-only commands are answered instead of being fed to the model. |
19
- | **Built-in commands** | `/status`, `/reset`, `/cancel`, `/help`. |
20
+ | **Built-in commands** | `/status`, `/queue`, `/reset`, `/cancel`, `/help`. |
20
21
 
21
22
  ## Install
22
23
 
@@ -42,6 +43,10 @@ Then create `~/.pi/agent/pi-qqbot.json`:
42
43
  "approve": {
43
44
  "tools": ["bash", "powershell", "write", "edit"],
44
45
  "remember": true
46
+ },
47
+ "queue": {
48
+ "max": 20,
49
+ "ack": true
45
50
  }
46
51
  }
47
52
  ```
@@ -56,6 +61,8 @@ Then create `~/.pi/agent/pi-qqbot.json`:
56
61
  | `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
57
62
  | `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
58
63
  | `commands` | see below | Slash-command routing gates: `passthrough`, `allowReload`, `allowQuit`, `allowSession` |
64
+ | `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
65
+ | `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
59
66
 
60
67
  Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
61
68
 
@@ -66,8 +73,9 @@ Start pi, then message the bot in QQ; pi handles it and replies.
66
73
  | Command | Description |
67
74
  |---|---|
68
75
  | `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
69
- | `/status` | Show busy/idle state, auto-approve state and pending approval count |
70
- | `/cancel` | **Interrupt the task that is running right now** (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
76
+ | `/status` | Show busy/idle state, queue length, auto-approve state and pending approval count |
77
+ | `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
78
+ | `/cancel` | **Interrupt the task that is running right now** and empty the queue (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
71
79
  | `/help` | Show help |
72
80
 
73
81
  ### Slash commands from QQ
@@ -104,6 +112,9 @@ Guards:
104
112
 
105
113
  - `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone` and `/resume` refuse to run while a task
106
114
  is in progress — send `/cancel` first.
115
+ - Prompt templates and `/skill:name` start an agent run, so while a task is in progress they are
116
+ **queued** like ordinary messages (see [Queueing while busy](#queueing-while-busy)). Extension commands
117
+ that manage their own LLM interaction still run immediately.
107
118
  - Selection commands (`/tree`, `/fork`, `/resume`) answer with a numbered list of at most 10 entries, scoped to
108
119
  the user who asked and valid for 10 minutes. The next bare number from that user executes the choice; anything
109
120
  else (or 「取消」) abandons it. A number without a pending list is ordinary chat.
@@ -115,6 +126,39 @@ Guards:
115
126
  hard-coded here because pi does not export its built-in list; any unknown `/word` falls back to
116
127
  plain text.
117
128
 
129
+ ### Queueing while busy
130
+
131
+ pi's session runs one agent run at a time, so the bot serializes QQ input itself:
132
+
133
+ ```text
134
+ you: 帮我把测试跑一遍
135
+ bot: 收到,正在处理
136
+ you: 顺便看看 README 有没有错别字
137
+ bot: 已排队 #1,当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。
138
+ you: /queue
139
+ bot: 当前任务:处理中(发 /cancel 可中断)
140
+ 队列:1 条(上限 20)
141
+ 1. #1 · 12s 前 · 顺便看看 README 有没有错别字
142
+ 发 /queue clear 清空,/queue drop <编号> 删除一条。
143
+ bot: (第一轮结束后)开始执行队列 #1
144
+ ```
145
+
146
+ Semantics:
147
+
148
+ - Every message that would start a run — plain chat, prompt templates, `/skill:name` — is queued while a
149
+ task is running, in arrival order (FIFO). Replies go to the QQ user who sent that message.
150
+ - The next entry starts when pi has **settled** (`agent_settled`, no automatic continuation left), so a
151
+ retry, a compaction retry or a follow-up turn is never cut in half.
152
+ - The queue holds at most `queue.max` entries; beyond that the message is refused with a notice. `queue.max: 0`
153
+ restores the old "上一条消息还在处理中" behaviour.
154
+ - If handing a message to pi fails (pi was still finishing the previous run), it is put back at the head of
155
+ the queue and retried — messages are not lost. A run that already started is never retried, so a failure
156
+ inside a run cannot execute the same message twice.
157
+ - Control commands are **not** queued: approvals, `/status`, `/queue`, `/reset`, `/help`, the pi built-ins
158
+ and `/cancel` all act immediately.
159
+ - `/cancel` empties the queue as well, because "cancel" means "stop doing all of this". Use `/queue drop <id>`
160
+ to remove a single entry instead.
161
+
118
162
  ### Cancelling a run
119
163
 
120
164
  Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
@@ -122,14 +166,16 @@ back to the machine:
122
166
 
123
167
  ```
124
168
  you: /cancel
125
- bot: 已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
169
+ bot: 已取消当前任务,并终止了 1 个待审批请求,队列中的 2 条已清空。可以发送新消息继续。
126
170
  ```
127
171
 
128
- What it does, in order: terminate every pending approval request, call `ctx.abort()` on the current
129
- run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is not
130
- sent to QQ, clear the busy flag so the next message is accepted immediately, and reply with a
131
- confirmation. Replies that only *look* like cancelling — 「取消自动同意」, 「恢复审批」 — still go to the
132
- approval handler, so the auto-approve switch keeps working as before.
172
+ What it does, in order: terminate every pending approval request, empty the queue, call `ctx.abort()` on
173
+ the current run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is
174
+ not sent to QQ, keep the busy flag until pi reports `agent_settled` (so messages arriving during the abort
175
+ are queued instead of racing it), and reply with a confirmation. If `agent_settled` never arrives, an
176
+ 8-second watchdog releases the busy flag and continues the queue. Replies that only *look* like cancelling
177
+ — 「取消自动同意」, 「恢复审批」 — still go to the approval handler, so the auto-approve switch keeps working
178
+ as before.
133
179
 
134
180
  Anything else is forwarded to pi as a user message.
135
181
 
package/config.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import { CONFIG_PATH } from "./constants";
3
3
  import { DEFAULT_COMMANDS, type CommandConfig } from "./slash";
4
+ import { DEFAULT_QUEUE_MAX } from "./queue";
5
+
6
+ export interface QueueConfig {
7
+ /** 忙碌时最多排队多少条消息;0 = 关闭排队(忙碌时直接拒绝) */
8
+ max: number;
9
+ /** 排队任务开始执行时是否给 QQ 回执(入队回执不受此开关控制) */
10
+ ack: boolean;
11
+ }
4
12
 
5
13
  export interface ApproveConfig {
6
14
  /** 需要 QQ 审批的工具名(如 bash/powershell/write/edit) */
@@ -20,6 +28,8 @@ export interface QQBotConfig {
20
28
  approve: ApproveConfig;
21
29
  /** QQ 侧斜杠命令的路由开关(/reload、/new… 以及扩展命令透传) */
22
30
  commands: CommandConfig;
31
+ /** 忙碌时的消息队列 */
32
+ queue: QueueConfig;
23
33
  }
24
34
 
25
35
  export function loadConfig(): QQBotConfig | null {
@@ -50,5 +60,9 @@ export function loadConfig(): QQBotConfig | null {
50
60
  allowQuit: file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
51
61
  allowSession: file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
52
62
  },
63
+ queue: {
64
+ max: file.queue?.max ?? DEFAULT_QUEUE_MAX,
65
+ ack: file.queue?.ack ?? true,
66
+ },
53
67
  };
54
68
  }
package/constants.ts CHANGED
@@ -10,6 +10,10 @@ export const MAX_REPLIES_PER_MSG_ID = 4;
10
10
  export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
11
11
  export const STREAM_FLUSH_INTERVAL_MS = 1200;
12
12
  export const GATEWAY_PATH = "/gateway";
13
+ /** 队列任务派发给 pi 失败(通常是上一轮还没收尾)时的重试间隔 */
14
+ export const DISPATCH_RETRY_MS = 3000;
15
+ /** /cancel 之后等 agent_settled 的宽限时间,超时就强制释放 busy */
16
+ export const CANCEL_SETTLE_GRACE_MS = 8000;
13
17
 
14
18
  const PI_DIR = path.join(os.homedir(), ".pi", "agent");
15
19
  export const CONFIG_PATH = () => path.join(PI_DIR, "pi-qqbot.json");
@@ -11,12 +11,13 @@
11
11
  | 功能 | 说明 |
12
12
  |---|---|
13
13
  | **对话驱动 agent** | QQ 单聊消息送入当前 pi 会话,AI 的回复发回 QQ(长文本自动分片)。 |
14
+ | **消息队列** | 正在处理任务时,后续 QQ 消息会被**排队**而不是被拒;当前任务结束(settled)后按到达顺序自动逐条执行。`/cancel` 是例外——立即生效并清空队列。 |
14
15
  | **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
15
16
  | **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
16
17
  | **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
17
- | **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答一并清理。 |
18
+ | **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答、排队中的消息一并清理。 |
18
19
  | **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
19
- | **内置指令** | `/status`、`/reset`、`/cancel`、`/help`。 |
20
+ | **内置指令** | `/status`、`/queue`、`/reset`、`/cancel`、`/help`。 |
20
21
 
21
22
  ## 安装
22
23
 
@@ -42,6 +43,10 @@ pi install ./pi-qqbot
42
43
  "approve": {
43
44
  "tools": ["bash", "powershell", "write", "edit"],
44
45
  "remember": true
46
+ },
47
+ "queue": {
48
+ "max": 20,
49
+ "ack": true
45
50
  }
46
51
  }
47
52
  ```
@@ -56,6 +61,8 @@ pi install ./pi-qqbot
56
61
  | `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
57
62
  | `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
58
63
  | `commands` | 见下 | 斜杠命令路由开关:`passthrough`、`allowReload`、`allowQuit`、`allowSession` |
64
+ | `queue.max` | `20` | 任务运行期间最多排队多少条;`0` = 关闭排队(忙碌时直接拒绍,回到旧行为) |
65
+ | `queue.ack` | `true` | 排队消息开始执行时是否回执 QQ(入队回执总是会发) |
59
66
 
60
67
  也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
61
68
 
@@ -66,8 +73,9 @@ pi install ./pi-qqbot
66
73
  | 指令 | 说明 |
67
74
  |---|---|
68
75
  | `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
69
- | `/status` | 查看忙闲状态、自动同意状态、待审批数量 |
70
- | `/cancel` | **中断当前正在运行的任务**(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
76
+ | `/status` | 查看忙闲状态、队列长度、自动同意状态、待审批数量 |
77
+ | `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
78
+ | `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
71
79
  | `/help` | 帮助 |
72
80
 
73
81
  ### QQ 里的斜杠命令
@@ -103,6 +111,8 @@ pi install ./pi-qqbot
103
111
 
104
112
  - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
105
113
  —— 先发 `/cancel`。
114
+ - prompt 模板与 `/skill:name` 会开启一轮 agent run,因此忙碌时会像普通消息一样**排队**
115
+ (见 [忙碌时的队列](#忙碌时的队列));自己管理 LLM 交互的扩展命令仍然立即执行。
106
116
  - 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
107
117
  该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
108
118
  - `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
@@ -112,17 +122,49 @@ pi install ./pi-qqbot
112
122
  改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
113
123
  `/xxx` 都会退回当普通文字。
114
124
 
125
+ ### 忙碌时的队列
126
+
127
+ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做:
128
+
129
+ ```text
130
+ 你:帮我把测试跑一遍
131
+ 机器人:收到,正在处理
132
+ 你:顺便看看 README 有没有错别字
133
+ 机器人:已排队 #1,当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。
134
+ 你:/queue
135
+ 机器人:当前任务:处理中(发 /cancel 可中断)
136
+ 队列:1 条(上限 20)
137
+ 1. #1 · 12s 前 · 顺便看看 README 有没有错别字
138
+ 发 /queue clear 清空,/queue drop <编号> 删除一条。
139
+ 机器人:(第一轮结束后)开始执行队列 #1
140
+ ```
141
+
142
+ 语义:
143
+
144
+ - 会开启一轮 run 的消息(普通对话、prompt 模板、`/skill:name`)在忙碌时按到达顺序(FIFO)排队;
145
+ 回复发给该条消息的发送者。
146
+ - 下一条在 pi **settled**(不再有任何自动续跑)之后才启动,所以重试、压缩重试、follow-up 轮次
147
+ 不会被从中间撗断。
148
+ - 队列上限为 `queue.max`,超出时拒绍入队并回执;`queue.max: 0` 回到旧的「上一条消息还在处理中」。
149
+ - 交给 pi 失败时(通常上一轮还没收尾)任务会放回队首重试,不丢消息;已经真的启动过 run 的报错
150
+ 不会重试,避免同一条消息执行两次。
151
+ - 控制类指令**不排队**:审批回复、`/status`、`/queue`、`/reset`、`/help`、pi 内置命令与 `/cancel`
152
+ 都立即生效。
153
+ - `/cancel` 会连队列一起清空(“取消”就是“都别做了”);只想删一条请用 `/queue drop <编号>`。
154
+
115
155
  ### 取消正在运行的任务
116
156
 
117
157
  从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
118
158
 
119
159
  ```
120
160
  你:/cancel
121
- 机器人:已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
161
+ 机器人:已取消当前任务,并终止了 1 个待审批请求,队列中的 2 条已清空。可以发送新消息继续。
122
162
  ```
123
163
 
124
- 执行顺序:先终止所有待审批请求,再对当前 run 调用 `ctx.abort()`(和 TUI 里按 Escape 同一条路径),
125
- 丢弃已经流式产生的半截回答(不会发到 QQ),清掉忙碌标记使下一条消息立刻可用,最后回执确认。
164
+ 执行顺序:先终止所有待审批请求、清空队列,再对当前 run 调用 `ctx.abort()`(和 TUI 里按 Escape 同一条路径),
165
+ 丢弃已经流式产生的半截回答(不会发到 QQ),**忙碌标记保留到 pi 报 `agent_settled`**
166
+ (所以 abort 期间新来的消息会排队,而不是和收尾中的 run 撞车),最后回执确认。如果 `agent_settled`
167
+ 一直不来,8 秒看门狗会强制释放忙碌标记并继续队列。
126
168
  像「取消自动同意」「恢复审批」这类**只是长得像**取消的回复仍然走审批分支,自动同意开关行为不变。
127
169
 
128
170
  其余文本都作为用户消息交给 pi。
package/index.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  PASSIVE_WINDOW_MS,
8
8
  APPROVAL_TIMEOUT_MS,
9
9
  LOCK_PATH,
10
+ DISPATCH_RETRY_MS,
11
+ CANCEL_SETTLE_GRACE_MS,
10
12
  } from "./constants";
11
13
  import { log } from "./logger";
12
14
  import { tryAcquireLock, startLockRetry } from "./lock";
@@ -15,6 +17,7 @@ import { QQApi } from "./qq/api";
15
17
  import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
16
18
  import { Approver } from "./approver";
17
19
  import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
20
+ import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
18
21
  import { ReplyBuffer } from "./stream-buffer";
19
22
  import { splitText } from "./util/chunk";
20
23
 
@@ -41,10 +44,19 @@ interface State {
41
44
  bridgeReply: ((text: string) => void) | null;
42
45
  /** 本轮已被用户取消:settle 时丢弃半截回复 */
43
46
  cancelled: boolean;
47
+ /** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
48
+ queue: MessageQueue;
49
+ /** 正在出队派发(防 settled / 重试竞态下重复派发) */
50
+ dispatching: boolean;
51
+ /** 当前派发的任务已经真的启动了 run(agent_start 过):此时报错不重试,避免重复执行 */
52
+ runStarted: boolean;
53
+ /** /cancel 之后等 agent_settled 的兜底定时器 */
54
+ cancelTimer: ReturnType<typeof setTimeout> | null;
44
55
  }
45
56
 
46
- export default function (pi: ExtensionAPI) {
47
- const state: State = {
57
+ /** 扩展运行所需的状态(导出只为测试用) */
58
+ export function createInitialState(): State {
59
+ return {
48
60
  cfg: null,
49
61
  api: null,
50
62
  approver: new Approver(APPROVAL_TIMEOUT_MS),
@@ -61,12 +73,26 @@ export default function (pi: ExtensionAPI) {
61
73
  pickerOwner: null,
62
74
  bridgeReply: null,
63
75
  cancelled: false,
76
+ queue: new MessageQueue(),
77
+ dispatching: false,
78
+ runStarted: false,
79
+ cancelTimer: null,
64
80
  };
81
+ }
82
+
83
+ export default function (
84
+ pi: ExtensionAPI,
85
+ /** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
86
+ options?: { state?: State },
87
+ ) {
88
+ const state: State = options?.state ?? createInitialState();
65
89
 
66
90
  // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
67
91
  pi.on("agent_start", (_event, ctx) => {
68
92
  state.ctx = ctx;
69
93
  state.cancelled = false;
94
+ state.runStarted = true;
95
+ clearCancelTimer(state);
70
96
  });
71
97
 
72
98
  // ---- 事件:累积 assistant 文本 ----
@@ -77,8 +103,9 @@ export default function (pi: ExtensionAPI) {
77
103
  }
78
104
  });
79
105
 
80
- // ---- 事件:run 结束,发回复 ----
106
+ // ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
81
107
  pi.on("agent_settled", async () => {
108
+ clearCancelTimer(state);
82
109
  const cancelled = state.cancelled;
83
110
  const text = state.replyBuffer.take();
84
111
  const openid = state.currentOpenid;
@@ -86,15 +113,17 @@ export default function (pi: ExtensionAPI) {
86
113
  state.busy = false;
87
114
  state.ctx = null;
88
115
  state.cancelled = false;
116
+ state.runStarted = false;
89
117
  if (cancelled) {
90
118
  log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
91
- return;
92
- }
93
- if (!text || !openid || !state.api) return;
94
- log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
95
- for (const chunk of splitText(text)) {
96
- await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
119
+ } else if (text && openid && state.api) {
120
+ log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
121
+ for (const chunk of splitText(text)) {
122
+ await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
123
+ }
97
124
  }
125
+ // 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
126
+ drainQueue(pi, state);
98
127
  });
99
128
 
100
129
  // ---- 事件:tool_call 审批 ----
@@ -163,7 +192,10 @@ export default function (pi: ExtensionAPI) {
163
192
  state.cfg = cfg;
164
193
  // 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
165
194
  state.approver.reset();
166
- log("[setup] 配置已加载 sandbox=", cfg.sandbox);
195
+ // 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
196
+ state.queue.setLimit(cfg.queue.max);
197
+ state.queue.clear();
198
+ log("[setup] 配置已加载 sandbox=", cfg.sandbox, "queue.max=", cfg.queue.max);
167
199
 
168
200
  const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
169
201
  const auth = new AuthManager(cfg.appId, cfg.appSecret);
@@ -204,6 +236,9 @@ export default function (pi: ExtensionAPI) {
204
236
  // ---- 事件:session_shutdown 清理 ----
205
237
  pi.on("session_shutdown", async () => {
206
238
  log("[shutdown] 清理资源");
239
+ clearCancelTimer(state);
240
+ const dropped = state.queue.clear();
241
+ if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
207
242
  if (state.stopRetry) state.stopRetry();
208
243
  state.gateway?.stop();
209
244
  if (state.releaseLock) {
@@ -248,49 +283,220 @@ function isCancelIntent(text: string): boolean {
248
283
  return RE_CANCEL.test(text.trim());
249
284
  }
250
285
 
286
+ /** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
287
+ const MAX_DISPATCH_ATTEMPTS = 3;
288
+
289
+ function clearCancelTimer(state: State) {
290
+ if (state.cancelTimer) {
291
+ clearTimeout(state.cancelTimer);
292
+ state.cancelTimer = null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * 对外看是“忙”:有任务在跑、正在派发,或者队列里还有任务等着(比如派发失败后的重试窗口)。
298
+ * 这三种情况新消息都应该进队列,而不是插队直接开新一轮。
299
+ */
300
+ function isBusy(state: State): boolean {
301
+ return state.busy || state.dispatching || state.queue.size > 0;
302
+ }
303
+
304
+ /**
305
+ * /cancel 之后 abort() 是异步的:正常路径靠 agent_settled 释放 busy 并继续队列;
306
+ * 万一 abort 没能触发 settled(比如那一轮正好已经结束),宽限期后强制解锁。
307
+ */
308
+ function armCancelWatchdog(pi: ExtensionAPI, state: State) {
309
+ clearCancelTimer(state);
310
+ const timer = setTimeout(() => {
311
+ state.cancelTimer = null;
312
+ if (!state.cancelled) return;
313
+ log("[cancel] abort 后", CANCEL_SETTLE_GRACE_MS, "ms 内没有 agent_settled,强制释放 busy");
314
+ state.cancelled = false;
315
+ state.busy = false;
316
+ state.ctx = null;
317
+ state.currentOpenid = null;
318
+ state.replyBuffer.reset();
319
+ drainQueue(pi, state);
320
+ }, CANCEL_SETTLE_GRACE_MS);
321
+ (timer as any).unref?.();
322
+ state.cancelTimer = timer;
323
+ }
324
+
325
+ /**
326
+ * 把一条任务交给 pi,并把它标记为「当前任务」。
327
+ * 不 await sendUserMessage:它返回的 promise 要等整轮跑完,await 会卡住调用方
328
+ * (agent_settled 里派发时更是不允许)。错误交给 onDispatchError 重试/回执。
329
+ */
330
+ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
331
+ state.currentOpenid = job.openid;
332
+ state.lastOpenid = job.openid;
333
+ state.pickerOwner = job.openid;
334
+ state.bridgeReply = (text: string) => {
335
+ void replyTo(state.api!, state, job.openid, text);
336
+ };
337
+ state.busy = true;
338
+ state.cancelled = false;
339
+ state.runStarted = false;
340
+ state.replyBuffer.reset();
341
+ clearCancelTimer(state);
342
+ log(
343
+ "[msg] 送入 pi:",
344
+ job.id > 0 ? `队列 #${job.id}` : "直接",
345
+ "openid=",
346
+ job.openid,
347
+ "text=",
348
+ job.text.slice(0, 60),
349
+ );
350
+ try {
351
+ void Promise.resolve(
352
+ pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
353
+ ).catch((error) => onDispatchError(pi, state, job, error));
354
+ } catch (error) {
355
+ onDispatchError(pi, state, job, error);
356
+ }
357
+ }
358
+
359
+ /** 派发失败:先重试(放回队首),多次失败就把错误回给发送者并继续队列 */
360
+ function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error: unknown) {
361
+ const message = String((error as any)?.message ?? error).slice(0, 200);
362
+ log("[msg] 派发失败:", message);
363
+ if (state.runStarted) {
364
+ // run 已经启动了:这次报错属于这一轮自己的问题,收尾交给 agent_settled,不能重试
365
+ log("[msg] 本轮已启动过 agent,不重试,等 agent_settled 收尾");
366
+ return;
367
+ }
368
+ state.busy = false;
369
+ state.currentOpenid = null;
370
+ job.attempts += 1;
371
+ if (job.attempts < MAX_DISPATCH_ATTEMPTS) {
372
+ state.queue.unshift(job);
373
+ log("[msg]", DISPATCH_RETRY_MS, "ms 后重试(第", job.attempts, "次)");
374
+ const timer = setTimeout(() => drainQueue(pi, state), DISPATCH_RETRY_MS);
375
+ (timer as any).unref?.();
376
+ return;
377
+ }
378
+ const api = state.api;
379
+ if (api) void replyTo(api, state, job.openid, `这条消息执行失败:${message}`);
380
+ drainQueue(pi, state);
381
+ }
382
+
383
+ /** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
384
+ function drainQueue(pi: ExtensionAPI, state: State) {
385
+ if (state.busy || state.dispatching) return;
386
+ const job = state.queue.shift();
387
+ if (!job) return;
388
+ state.dispatching = true;
389
+ try {
390
+ log("[queue] 出队 #", job.id, "剩余", state.queue.size, "条", "text=", job.text.slice(0, 60));
391
+ const api = state.api;
392
+ if (api && state.cfg?.queue.ack !== false) {
393
+ const rest = state.queue.size > 0 ? `(后面还有 ${state.queue.size} 条)` : "";
394
+ void replyTo(api, state, job.openid, `开始执行队列 #${job.id}${rest}`);
395
+ }
396
+ startJob(pi, state, job);
397
+ } finally {
398
+ state.dispatching = false;
399
+ }
400
+ }
401
+
402
+ /** 入队(忙碌时的统一入口):回执排队位置 */
403
+ async function enqueueJob(
404
+ api: QQApi,
405
+ state: State,
406
+ openid: string,
407
+ text: string,
408
+ ): Promise<boolean> {
409
+ const result = state.queue.push(openid, text);
410
+ if (!result.ok) {
411
+ log("[queue] 队列已满(上限", state.queue.max, "),拒绝入队");
412
+ if (state.queue.max === 0) {
413
+ await replyTo(api, state, openid, "上一条消息还在处理中,请稍候…(队列已关闭:queue.max=0)");
414
+ return false;
415
+ }
416
+ await replyTo(
417
+ api,
418
+ state,
419
+ openid,
420
+ `队列已满(上限 ${state.queue.max} 条),这条没排上。发 /queue 查看队列,或 /cancel 清空后重发。`,
421
+ );
422
+ return false;
423
+ }
424
+ log("[queue] 入队 #", result.job.id, "size=", state.queue.size, "ahead=", result.ahead);
425
+ await replyTo(
426
+ api,
427
+ state,
428
+ openid,
429
+ `已排队 #${result.job.id}${result.ahead > 0 ? `(前面还有 ${result.ahead} 条)` : ""},当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。`,
430
+ );
431
+ return true;
432
+ }
433
+
434
+ function formatQueue(state: State): string {
435
+ const lines = [`当前任务:${state.busy ? "处理中(发 /cancel 可中断)" : "空闲"}`];
436
+ const jobs = state.queue.list();
437
+ if (jobs.length === 0) {
438
+ lines.push("队列:空");
439
+ return lines.join("\n");
440
+ }
441
+ lines.push(`队列:${jobs.length} 条(上限 ${state.queue.max})`);
442
+ jobs.slice(0, 10).forEach((job, index) => {
443
+ lines.push(` ${index + 1}. #${job.id} · ${agoLabel(job.enqueuedAt)} · ${previewText(job.text)}`);
444
+ });
445
+ if (jobs.length > 10) lines.push(` … 还有 ${jobs.length - 10} 条`);
446
+ lines.push("发 /queue clear 清空,/queue drop <编号> 删除一条。");
447
+ return lines.join("\n");
448
+ }
449
+
251
450
  /**
252
- * 用户发来取消指令:终止待审批、abort 当前 run、丢弃半截回复,然后回执。
451
+ * 用户发来取消指令:终止待审批、清空队列、abort 当前 run、丢弃半截回复,然后回执。
452
+ * 取消本身永不入队(“取消除外”),立即生效。
253
453
  * (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
254
454
  */
255
455
  async function handleCancel(
456
+ pi: ExtensionAPI,
256
457
  state: State,
257
458
  api: QQApi,
258
459
  openid: string,
259
460
  ): Promise<void> {
260
461
  const terminated = state.approver.terminateAll();
462
+ const dropped = state.queue.clear();
261
463
  const running = state.busy || Boolean(state.ctx?.signal);
262
-
263
464
  if (!running) {
465
+ const parts: string[] = [];
466
+ if (terminated > 0) parts.push(`已终止 ${terminated} 个待审批请求`);
467
+ if (dropped.length > 0) parts.push(`已清空队列(${dropped.length} 条)`);
264
468
  await replyTo(
265
469
  api,
266
470
  state,
267
471
  openid,
268
- terminated > 0
269
- ? `已终止 ${terminated} 个待审批请求,当前没有正在运行的任务。`
472
+ parts.length > 0
473
+ ? `${parts.join(",")},当前没有正在运行的任务。`
270
474
  : "当前没有正在运行的任务。",
271
475
  );
272
476
  return;
273
477
  }
274
478
 
275
479
  state.cancelled = true;
276
- state.busy = false;
277
480
  state.replyBuffer.reset();
278
481
  state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
279
482
  const ctx = state.ctx;
280
483
  state.ctx = null;
281
484
  try {
282
485
  ctx?.abort();
283
- log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个");
486
+ log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个,清空队列", dropped.length, "条");
284
487
  } catch (e) {
285
488
  log("[cancel] abort 失败:", String(e));
286
489
  }
490
+ // busy 保持 true 直到 agent_settled:期间新消息会排队,不会和正在收尾的 run 撞车
491
+ armCancelWatchdog(pi, state);
492
+ const notice: string[] = [];
493
+ if (terminated > 0) notice.push(`并终止了 ${terminated} 个待审批请求`);
494
+ if (dropped.length > 0) notice.push(`队列中的 ${dropped.length} 条已清空`);
287
495
  await replyTo(
288
496
  api,
289
497
  state,
290
498
  openid,
291
- terminated > 0
292
- ? `已取消当前任务,并终止了 ${terminated} 个待审批请求。可以发送新消息继续。`
293
- : "已取消当前任务。可以发送新消息继续。",
499
+ `已取消当前任务${notice.length > 0 ? `,${notice.join(",")}` : ""}。可以发送新消息继续。`,
294
500
  );
295
501
  }
296
502
 
@@ -341,9 +547,9 @@ export async function handleMessage(
341
547
  }
342
548
  }
343
549
 
344
- // 取消指令优先于审批解析:「取消」不该被当成审批动作
550
+ // 取消指令优先于审批解析:「取消」不该被当成审批动作;取消也永不入队
345
551
  if (isCancelIntent(msg.content)) {
346
- await handleCancel(state, api, msg.openid);
552
+ await handleCancel(pi, state, api, msg.openid);
347
553
  return;
348
554
  }
349
555
 
@@ -355,10 +561,30 @@ export async function handleMessage(
355
561
  };
356
562
  const routed = await routeSlash(
357
563
  {
358
- listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string }[],
359
- dispatch: (text: string) => pi.sendUserMessage(text, { expandPromptTemplates: true }),
564
+ listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
565
+ dispatch: (text, options) => {
566
+ // 提示模板 / /skill: 会开启一轮 agent run:和普通对话一样串行到队列
567
+ if (options?.runsTurn) {
568
+ if (!isBusy(state)) {
569
+ startJob(pi, state, state.queue.mint(msg.openid, text));
570
+ return "dispatched";
571
+ }
572
+ const queued = state.queue.push(msg.openid, text);
573
+ if (!queued.ok) {
574
+ log("[queue] 队列已满,命令未排队:", text.slice(0, 60));
575
+ return "rejected";
576
+ }
577
+ log("[queue] 命令入队 #", queued.job.id, text.slice(0, 60));
578
+ return "queued";
579
+ }
580
+ // 扩展命令 / 桥接命令(/qq …)不开 run,pi 允许流式期间执行,直接派发
581
+ void Promise.resolve(pi.sendUserMessage(text, { expandPromptTemplates: true })).catch(
582
+ (e) => log("[cmd] 派发失败:", String(e)),
583
+ );
584
+ return "dispatched";
585
+ },
360
586
  reply: (text: string) => replyTo(api, state, msg.openid, text),
361
- busy: state.busy,
587
+ busy: isBusy(state),
362
588
  cfg: cfg.commands,
363
589
  log,
364
590
  },
@@ -421,60 +647,60 @@ export async function handleMessage(
421
647
 
422
648
  // 内置指令
423
649
  const cmd = parseCommand(msg.content);
424
- if (cmd === "help") {
650
+ if (cmd?.name === "help") {
425
651
  await replyTo(
426
652
  api,
427
653
  state,
428
654
  msg.openid,
429
655
  [
430
656
  "pi-qqbot 指令:",
431
- "/status — 查看状态(忙闲 / 自动同意 / 待审批)",
657
+ "/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批)",
658
+ "/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
432
659
  "/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
433
- "/cancel(或「取消」「停止」)— 中断正在运行的任务",
660
+ "/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
434
661
  "/help — 帮助",
435
662
  "pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
663
+ "忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
436
664
  "其余文本交给 pi 当作对话处理。",
437
665
  ].join("\n"),
438
666
  );
439
667
  return;
440
668
  }
441
- if (cmd === "status") {
669
+ if (cmd?.name === "status") {
442
670
  await replyTo(
443
671
  api,
444
672
  state,
445
673
  msg.openid,
446
674
  [
447
- `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : "空闲"}`,
675
+ `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
676
+ `队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
448
677
  `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
449
678
  `待审批: ${state.approver.pendingCount}`,
450
679
  ].join("\n"),
451
680
  );
452
681
  return;
453
682
  }
454
- if (cmd === "reset") {
683
+ if (cmd?.name === "queue") {
684
+ await handleQueueCommand(api, state, msg.openid, cmd.args);
685
+ return;
686
+ }
687
+ if (cmd?.name === "reset") {
455
688
  state.replyBuffer.reset();
456
689
  state.approver.reset();
457
690
  await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
458
691
  return;
459
692
  }
460
693
 
461
- // 忙检查:单会话串行处理
462
- if (state.busy) {
463
- await replyTo(api, state, msg.openid, "上一条消息还在处理中,请稍候…");
694
+ // 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
695
+ if (isBusy(state)) {
696
+ await enqueueJob(api, state, msg.openid, msg.content);
464
697
  return;
465
698
  }
466
699
 
467
700
  // ack
468
701
  await replyTo(api, state, msg.openid, "收到,正在处理");
469
702
 
470
- state.currentOpenid = msg.openid;
471
- state.busy = true;
472
- state.cancelled = false;
473
- state.replyBuffer.reset();
474
-
475
- // 交给 pi 处理
476
- pi.sendUserMessage(msg.content, { deliverAs: "followUp" });
477
- log("[msg] 已送入 pi:", msg.content.slice(0, 60));
703
+ startJob(pi, state, state.queue.mint(msg.openid, msg.content));
478
704
  } catch (e) {
479
705
  log("[msg] 处理失败:", String(e));
480
706
  await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
@@ -500,11 +726,53 @@ async function replyTo(
500
726
  }
501
727
  }
502
728
 
503
- function parseCommand(text: string): "reset" | "status" | "help" | null {
729
+ function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue"; args: string } | null {
504
730
  // 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
505
731
  // 本地的「清缓冲/清审批记忆」改名为 /reset。
506
- const m = /^\/(reset|status|help)\s*$/i.exec(text.trim());
507
- return m ? (m[1].toLowerCase() as "reset" | "status" | "help") : null;
732
+ const m = /^\/(reset|status|help|queue|q)(?:\s+([\s\S]*))?$/i.exec(text.trim());
733
+ if (!m) return null;
734
+ const raw = m[1].toLowerCase();
735
+ return {
736
+ name: (raw === "q" ? "queue" : raw) as "reset" | "status" | "help" | "queue",
737
+ args: (m[2] ?? "").trim(),
738
+ };
739
+ }
740
+
741
+ /** /queue [clear|drop <编号>] */
742
+ async function handleQueueCommand(api: QQApi, state: State, openid: string, args: string) {
743
+ const parts = args.split(/\s+/).filter(Boolean);
744
+ const sub = parts[0]?.toLowerCase() ?? "";
745
+
746
+ if (!sub) {
747
+ await replyTo(api, state, openid, formatQueue(state));
748
+ return;
749
+ }
750
+ if (/^(clear|reset|清空)$/i.test(sub)) {
751
+ const dropped = state.queue.clear();
752
+ await replyTo(
753
+ api,
754
+ state,
755
+ openid,
756
+ dropped.length > 0 ? `已清空队列(${dropped.length} 条)。` : "队列本来就是空的。",
757
+ );
758
+ return;
759
+ }
760
+ if (/^(drop|del|rm|删除|删|去掉)$/i.test(sub)) {
761
+ const id = Number.parseInt(parts[1] ?? "", 10);
762
+ if (!Number.isFinite(id)) {
763
+ await replyTo(api, state, openid, "用法:/queue drop <编号>(编号见 /queue)");
764
+ return;
765
+ }
766
+ const removed = state.queue.remove(id);
767
+ await replyTo(
768
+ api,
769
+ state,
770
+ openid,
771
+ removed ? `已从队列删除 #${id}:${previewText(removed.text)}` : `队列里没有 #${id},发 /queue 查看。`,
772
+ );
773
+ return;
774
+ }
775
+ await replyTo(api, state, openid, "用法:/queue 查看 / /queue clear 清空 / /queue drop <编号> 删一条");
508
776
  }
509
777
 
510
778
  /** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
6
6
  "license": "MIT",
@@ -26,6 +26,9 @@
26
26
  "./index.ts"
27
27
  ]
28
28
  },
29
+ "scripts": {
30
+ "test": "node test/queue.test.mjs"
31
+ },
29
32
  "files": [
30
33
  "*.ts",
31
34
  "qq",
package/queue.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * QQ 侧任务队列。
3
+ *
4
+ * pi 的单会话是串行执行的:一轮 agent run 结束(agent_settled)后才可能开始下一轮。
5
+ * 于是忙碌期间收到的消息不再丢弃/拒绝,而是排队;当前任务结束后由 index.ts 的
6
+ * `drainQueue()` 逐条取出执行。
7
+ *
8
+ * 设计要点:
9
+ * - 全局单队列(本扩展是单会话设计),每条任务记住自己的 openid,回复发给原主人。
10
+ * - FIFO,编号单调递增不复用,方便用户用 `/queue drop <编号>` 定点删除。
11
+ * - `/cancel` 不走队列(立即生效),并且会清空队列。
12
+ */
13
+
14
+ export interface QueuedJob {
15
+ /** 队列编号,从 1 开始单调递增 */
16
+ id: number;
17
+ /** 任务来自哪个 QQ 用户 */
18
+ openid: string;
19
+ /** 交给 pi 的原始文本(普通对话 / 斜杠命令 / 提示模板 / skill 命令) */
20
+ text: string;
21
+ /** 入队时间戳(毫秒) */
22
+ enqueuedAt: number;
23
+ /** 派发失败的重试次数,由 index.ts 维护 */
24
+ attempts: number;
25
+ }
26
+
27
+ export type PushResult =
28
+ | { ok: true; job: QueuedJob; ahead: number }
29
+ | { ok: false; reason: "full" };
30
+
31
+ export const DEFAULT_QUEUE_MAX = 20;
32
+
33
+ /** 单条预览的最大字符数 */
34
+ const PREVIEW_MAX = 48;
35
+
36
+ export function previewText(text: string, max = PREVIEW_MAX): string {
37
+ const flat = text.replace(/\s+/g, " ").trim();
38
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
39
+ }
40
+
41
+ /** 距离入队多久:`12s 前` / `3m 前` / `1h 前` */
42
+ export function agoLabel(at: number, now = Date.now()): string {
43
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
44
+ if (seconds < 60) return `${seconds}s 前`;
45
+ const minutes = Math.floor(seconds / 60);
46
+ if (minutes < 60) return `${minutes}m 前`;
47
+ return `${Math.floor(minutes / 60)}h 前`;
48
+ }
49
+
50
+ export class MessageQueue {
51
+ private jobs: QueuedJob[] = [];
52
+ private nextId = 1;
53
+ private limit: number;
54
+
55
+ /** `limit = 0` 表示关闭排队(忙碌时直接拒绝新消息) */
56
+ constructor(limit = DEFAULT_QUEUE_MAX) {
57
+ this.limit = Math.max(0, Math.floor(limit) || 0);
58
+ }
59
+
60
+ /** 按配置调整上限;已有的任务不会被丢弃 */
61
+ setLimit(limit: number) {
62
+ this.limit = Math.max(0, Math.floor(limit) || 0);
63
+ }
64
+
65
+ get size(): number {
66
+ return this.jobs.length;
67
+ }
68
+
69
+ get max(): number {
70
+ return this.limit;
71
+ }
72
+
73
+ /** 只读快照(保持插入顺序) */
74
+ list(): readonly QueuedJob[] {
75
+ return this.jobs;
76
+ }
77
+
78
+ push(openid: string, text: string): PushResult {
79
+ if (this.limit === 0 || this.jobs.length >= this.limit) return { ok: false, reason: "full" };
80
+ const job: QueuedJob = {
81
+ id: this.nextId++,
82
+ openid,
83
+ text,
84
+ enqueuedAt: Date.now(),
85
+ attempts: 0,
86
+ };
87
+ this.jobs.push(job);
88
+ return { ok: true, job, ahead: this.jobs.length - 1 };
89
+ }
90
+
91
+ /** 手动构造一条不入队的任务(直接执行 / 失败重试时用) */
92
+ mint(openid: string, text: string): QueuedJob {
93
+ return { id: 0, openid, text, enqueuedAt: Date.now(), attempts: 0 };
94
+ }
95
+
96
+ shift(): QueuedJob | undefined {
97
+ return this.jobs.shift();
98
+ }
99
+
100
+ /** 派发失败时放回队首重试 */
101
+ unshift(job: QueuedJob): void {
102
+ this.jobs.unshift(job);
103
+ }
104
+
105
+ /** 按编号删除一条 */
106
+ remove(id: number): QueuedJob | undefined {
107
+ const index = this.jobs.findIndex((job) => job.id === id);
108
+ if (index === -1) return undefined;
109
+ return this.jobs.splice(index, 1)[0];
110
+ }
111
+
112
+ /** 清空并返回被清掉的任务 */
113
+ clear(): QueuedJob[] {
114
+ const dropped = this.jobs;
115
+ this.jobs = [];
116
+ return dropped;
117
+ }
118
+ }
package/slash.ts CHANGED
@@ -114,8 +114,13 @@ export const DEFAULT_COMMANDS: CommandConfig = {
114
114
  export interface RouteDeps {
115
115
  /** commands pi currently knows about (extension/prompt/skill) */
116
116
  listPiCommands(): readonly { name: string; source?: string }[];
117
- /** deliver a pi command through pi's own dispatcher */
118
- dispatch(text: string): void;
117
+ /**
118
+ * deliver a pi command through pi's own dispatcher.
119
+ * `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
120
+ * those are serialized through the bot's queue, everything else runs immediately.
121
+ * Returns "queued" when the bot deferred it, "rejected" when the queue was full.
122
+ */
123
+ dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
119
124
  /** answer the QQ user */
120
125
  reply(text: string): Promise<void>;
121
126
  busy: boolean;
@@ -123,6 +128,8 @@ export interface RouteDeps {
123
128
  log(message: string, ...rest: unknown[]): void;
124
129
  }
125
130
 
131
+ export type DispatchResult = "dispatched" | "queued" | "rejected";
132
+
126
133
  export type RouteResult = "dispatched" | "handled" | "text";
127
134
 
128
135
  const allowed = (cfg: CommandConfig, name: string): boolean => {
@@ -139,11 +146,19 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
139
146
 
140
147
  // 1. pi extension commands, prompt templates and skill commands
141
148
  if (deps.cfg.passthrough) {
142
- const known = deps.listPiCommands().some((c) => c.name.toLowerCase() === name);
149
+ const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
143
150
  if (known) {
144
- deps.log("[cmd] 派发 pi 扩展命令:", parsed.raw.slice(0, 80));
145
- deps.dispatch(parsed.raw);
146
- await deps.reply(`已派发 /${name} 给 pi。`);
151
+ // 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
152
+ const runsTurn = known.source === "prompt" || known.source === "skill";
153
+ deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
154
+ const result = deps.dispatch(parsed.raw, { runsTurn });
155
+ if (result === "queued") {
156
+ await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
157
+ } else if (result === "rejected") {
158
+ await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
159
+ } else {
160
+ await deps.reply(`已派发 /${name} 给 pi。`);
161
+ }
147
162
  return "dispatched";
148
163
  }
149
164
  }