@zxh126/pi-qqbot 0.2.1 → 0.3.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
@@ -14,7 +14,8 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
14
14
  | **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
15
15
  | **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
16
16
  | **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
17
- | **Built-in commands** | `/new`, `/status`, `/help`. |
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
+ | **Built-in commands** | `/new`, `/status`, `/cancel`, `/help`. |
18
19
 
19
20
  ## Install
20
21
 
@@ -64,8 +65,25 @@ Start pi, then message the bot in QQ; pi handles it and replies.
64
65
  |---|---|
65
66
  | `/new` | Reset: clear the reply buffer, approval memory and session-wide auto-approve |
66
67
  | `/status` | Show busy/idle state, auto-approve state and pending approval count |
68
+ | `/cancel` | **Interrupt the task that is running right now** (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
67
69
  | `/help` | Show help |
68
70
 
71
+ ### Cancelling a run
72
+
73
+ Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
74
+ back to the machine:
75
+
76
+ ```
77
+ you: /cancel
78
+ bot: 已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
79
+ ```
80
+
81
+ What it does, in order: terminate every pending approval request, call `ctx.abort()` on the current
82
+ run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is not
83
+ sent to QQ, clear the busy flag so the next message is accepted immediately, and reply with a
84
+ confirmation. Replies that only *look* like cancelling — 「取消自动同意」, 「恢复审批」 — still go to the
85
+ approval handler, so the auto-approve switch keeps working as before.
86
+
69
87
  Anything else is forwarded to pi as a user message.
70
88
 
71
89
  ## Remote approval
@@ -82,7 +100,7 @@ Reply vocabulary (approval keywords are Chinese, with English aliases):
82
100
  |---|---|
83
101
  | Approve one request | `同意 1` / `allow 1` — a bare `同意` / `allow` applies to the oldest pending request |
84
102
  | Reject one request | `拒绝 1` / `deny 1` — a bare `拒绝` / `deny` applies to the oldest pending request |
85
- | Abort the pending requests | `停止` |
103
+ | Abort the pending requests *and* the running task | `停止` / `/cancel` |
86
104
  | Allow everything in this session | `以后都同意`, `一直同意`, `全部放行`, `别再问了`, `always allow` |
87
105
  | Back to per-request approval | `取消自动同意`, `恢复审批`, `以后都不同意` |
88
106
 
package/approver.ts CHANGED
@@ -29,7 +29,7 @@ const RE_ALWAYS =
29
29
  /** 「取消自动同意」类表达(含「以后都不同意」) */
30
30
  const RE_REVOKE =
31
31
  /(取消|关闭|结束|停止)\s*自动同意|^恢复审批$|(以后|之后|今后|都|全部)\s*不(同意|允许|批准|放行)/;
32
- /** 「停止」终止本轮 */
32
+ /** 「停止」终止本轮(index.ts 里同时会 abort 当前 run) */
33
33
  const RE_TERMINATE = /^停止$/;
34
34
  /** 带或不带编号的批准/拒绝 */
35
35
  const RE_DECIDE = /^(同意|批准|放行|允许|allow|拒绝|驳回|deny)\s*(\d+)?$/i;
@@ -80,7 +80,7 @@ export class Approver {
80
80
  const item = this.pending.get(seq);
81
81
  return [
82
82
  `[审批 #${seq}] ${item?.summary ?? ""}`,
83
- `回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」终止本轮。`,
83
+ `回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」(/cancel)中断本轮任务。`,
84
84
  `要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。`,
85
85
  ].join("\n");
86
86
  }
@@ -14,7 +14,8 @@
14
14
  | **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
15
15
  | **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
16
16
  | **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
17
- | **内置指令** | `/new`、`/status`、`/help`。 |
17
+ | **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答一并清理。 |
18
+ | **内置指令** | `/new`、`/status`、`/cancel`、`/help`。 |
18
19
 
19
20
  ## 安装
20
21
 
@@ -64,8 +65,22 @@ pi install ./pi-qqbot
64
65
  |---|---|
65
66
  | `/new` | 重置:清空回复缓冲、审批记忆与自动同意开关 |
66
67
  | `/status` | 查看忙闲状态、自动同意状态、待审批数量 |
68
+ | `/cancel` | **中断当前正在运行的任务**(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
67
69
  | `/help` | 帮助 |
68
70
 
71
+ ### 取消正在运行的任务
72
+
73
+ 从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
74
+
75
+ ```
76
+ 你:/cancel
77
+ 机器人:已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
78
+ ```
79
+
80
+ 执行顺序:先终止所有待审批请求,再对当前 run 调用 `ctx.abort()`(和 TUI 里按 Escape 同一条路径),
81
+ 丢弃已经流式产生的半截回答(不会发到 QQ),清掉忙碌标记使下一条消息立刻可用,最后回执确认。
82
+ 像「取消自动同意」「恢复审批」这类**只是长得像**取消的回复仍然走审批分支,自动同意开关行为不变。
83
+
69
84
  其余文本都作为用户消息交给 pi。
70
85
 
71
86
  ## 远程审批
@@ -82,7 +97,7 @@ pi install ./pi-qqbot
82
97
  |---|---|
83
98
  | 批准某条 | `同意 1` / `allow 1`;裸 `同意` / `allow` 作用于最早的一条待审批 |
84
99
  | 拒绝某条 | `拒绝 1` / `deny 1`;裸 `拒绝` / `deny` 作用于最早的一条待审批 |
85
- | 终止当前待审批 | `停止` |
100
+ | 终止待审批 **并中断当前任务** | `停止` / `/cancel` |
86
101
  | 本会话全部放行 | `以后都同意`、`一直同意`、`全部放行`、`别再问了`、`always allow` |
87
102
  | 恢复逐条审批 | `取消自动同意`、`恢复审批`、`以后都不同意` |
88
103
 
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { loadConfig, QQBotConfig } from "./config";
3
3
  import {
4
4
  REST_BASE_PROD,
@@ -28,6 +28,10 @@ interface State {
28
28
  stopRetry: (() => void) | null;
29
29
  passiveRefs: Map<string, { msgId: string; receivedAt: number }>;
30
30
  busy: boolean;
31
+ /** 当前 run 的上下文(用来 abort);run 结束或取消后清空 */
32
+ ctx: ExtensionContext | null;
33
+ /** 本轮已被用户取消:settle 时丢弃半截回复 */
34
+ cancelled: boolean;
31
35
  }
32
36
 
33
37
  export default function (pi: ExtensionAPI) {
@@ -42,8 +46,16 @@ export default function (pi: ExtensionAPI) {
42
46
  stopRetry: null,
43
47
  passiveRefs: new Map(),
44
48
  busy: false,
49
+ ctx: null,
50
+ cancelled: false,
45
51
  };
46
52
 
53
+ // ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
54
+ pi.on("agent_start", (_event, ctx) => {
55
+ state.ctx = ctx;
56
+ state.cancelled = false;
57
+ });
58
+
47
59
  // ---- 事件:累积 assistant 文本 ----
48
60
  pi.on("message_update", (event) => {
49
61
  const ev = event.assistantMessageEvent as any;
@@ -54,10 +66,17 @@ export default function (pi: ExtensionAPI) {
54
66
 
55
67
  // ---- 事件:run 结束,发回复 ----
56
68
  pi.on("agent_settled", async () => {
69
+ const cancelled = state.cancelled;
57
70
  const text = state.replyBuffer.take();
58
71
  const openid = state.currentOpenid;
59
72
  state.currentOpenid = null;
60
73
  state.busy = false;
74
+ state.ctx = null;
75
+ state.cancelled = false;
76
+ if (cancelled) {
77
+ log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
78
+ return;
79
+ }
61
80
  if (!text || !openid || !state.api) return;
62
81
  log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
63
82
  for (const chunk of splitText(text)) {
@@ -187,8 +206,62 @@ export default function (pi: ExtensionAPI) {
187
206
  });
188
207
  }
189
208
 
209
+ /** 取消/停止这类「打断当前任务」的表达 */
210
+ const RE_CANCEL =
211
+ /^(\/cancel|\/stop|\/abort|取消|停止|中止|终止|算了|cancel|stop|abort)$/i;
212
+
213
+ function isCancelIntent(text: string): boolean {
214
+ return RE_CANCEL.test(text.trim());
215
+ }
216
+
217
+ /**
218
+ * 用户发来取消指令:终止待审批、abort 当前 run、丢弃半截回复,然后回执。
219
+ * (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
220
+ */
221
+ async function handleCancel(
222
+ state: State,
223
+ api: QQApi,
224
+ openid: string,
225
+ ): Promise<void> {
226
+ const terminated = state.approver.terminateAll();
227
+ const running = state.busy || Boolean(state.ctx?.signal);
228
+
229
+ if (!running) {
230
+ await replyTo(
231
+ api,
232
+ state,
233
+ openid,
234
+ terminated > 0
235
+ ? `已终止 ${terminated} 个待审批请求,当前没有正在运行的任务。`
236
+ : "当前没有正在运行的任务。",
237
+ );
238
+ return;
239
+ }
240
+
241
+ state.cancelled = true;
242
+ state.busy = false;
243
+ state.replyBuffer.reset();
244
+ state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
245
+ const ctx = state.ctx;
246
+ state.ctx = null;
247
+ try {
248
+ ctx?.abort();
249
+ log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个");
250
+ } catch (e) {
251
+ log("[cancel] abort 失败:", String(e));
252
+ }
253
+ await replyTo(
254
+ api,
255
+ state,
256
+ openid,
257
+ terminated > 0
258
+ ? `已取消当前任务,并终止了 ${terminated} 个待审批请求。可以发送新消息继续。`
259
+ : "已取消当前任务。可以发送新消息继续。",
260
+ );
261
+ }
262
+
190
263
  // ---- QQ 消息处理 ----
191
- async function handleMessage(
264
+ export async function handleMessage(
192
265
  pi: ExtensionAPI,
193
266
  state: State,
194
267
  api: QQApi,
@@ -203,6 +276,12 @@ async function handleMessage(
203
276
  }
204
277
  state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
205
278
 
279
+ // 取消指令优先于审批解析:「取消」不该被当成审批动作
280
+ if (isCancelIntent(msg.content)) {
281
+ await handleCancel(state, api, msg.openid);
282
+ return;
283
+ }
284
+
206
285
  // 远程审批回复优先
207
286
  const parsed = state.approver.parseReply(msg.content.trim());
208
287
  if (parsed) {
@@ -263,7 +342,14 @@ async function handleMessage(
263
342
  api,
264
343
  state,
265
344
  msg.openid,
266
- ["pi-qqbot 指令:", "/new — 开新会话", "/status — 查看状态", "/help — 帮助", "其余文本交给 pi 处理。"].join("\n"),
345
+ [
346
+ "pi-qqbot 指令:",
347
+ "/new — 开新会话(清空审批记忆/自动同意)",
348
+ "/status — 查看状态",
349
+ "/cancel(或「取消」「停止」)— 中断正在运行的任务",
350
+ "/help — 帮助",
351
+ "其余文本交给 pi 处理。",
352
+ ].join("\n"),
267
353
  );
268
354
  return;
269
355
  }
@@ -273,7 +359,7 @@ async function handleMessage(
273
359
  state,
274
360
  msg.openid,
275
361
  [
276
- `状态: ${state.busy ? "处理中" : "空闲"}`,
362
+ `状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : "空闲"}`,
277
363
  `自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
278
364
  `待审批: ${state.approver.pendingCount}`,
279
365
  ].join("\n"),
@@ -298,6 +384,7 @@ async function handleMessage(
298
384
 
299
385
  state.currentOpenid = msg.openid;
300
386
  state.busy = true;
387
+ state.cancelled = false;
301
388
  state.replyBuffer.reset();
302
389
 
303
390
  // 交给 pi 处理
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",