pi-lavish 0.0.0-stage → 0.1.1
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 +89 -2
- package/extensions/lavish.ts +601 -0
- package/package.json +38 -4
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-lavish
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lavish Editor([`lavish-axi`](https://github.com/kunchenguid/lavish-axi))在 pi 里的集成:**把反馈轮询从 agent 的前台 bash 搬到扩展的后台监听**,用户点 Send 时自动唤醒一个新回合。
|
|
4
|
+
|
|
5
|
+
不做这个扩展,pi 里跑 `lavish-axi poll` 会独占整个 turn——人可能在浏览器里慢慢标注五分钟,模型这五分钟什么都干不了,也看不到你的疑问。
|
|
6
|
+
|
|
7
|
+
配套的调研与前后时序图对比见 `~/workspace/video-collect/docs/lavish-axi-pi.md`。
|
|
8
|
+
|
|
9
|
+
## 它做什么
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
agent: npx -y lavish-axi .lavish/plan.html ← 扩展看到这条命令的 tool_result,自动接管监听
|
|
13
|
+
↓
|
|
14
|
+
turn 正常结束,pi 空闲(可以继续别的活)
|
|
15
|
+
↓
|
|
16
|
+
用户点 Send ──→ 扩展收到反馈 ──→ pi.sendUserMessage(反馈) ──→ 新回合
|
|
17
|
+
↓
|
|
18
|
+
agent 改 HTML → npx -y lavish-axi reply … "改了什么" ──→ 扩展续上监听,等下一轮
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
具体行为:
|
|
22
|
+
|
|
23
|
+
| 事件 | 扩展动作 |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `tool_result` 里出现 `lavish-axi <file>`(`status: opened/resumed`) | 自动开始监听该文件 |
|
|
26
|
+
| agent 想手跑 `lavish-axi poll <file>` | **拦下**(同一文件只能有一个 listener,扩展已占位) |
|
|
27
|
+
| poll 取回反馈 | 组一条结构化 user message 投给模型;空闲立即触发新回合,忙碌则 `followUp` 排到工具跑完之后 |
|
|
28
|
+
| 交付后 | 暂停监听,等 `agent_settled`(本轮改完)再续上,避免用户连发几条被拆成多个回合;10 分钟兜底 |
|
|
29
|
+
| `lavish-axi reply …` 成功 | 立刻续上监听,不等回合结束 |
|
|
30
|
+
| `Send & End` / `browser_disconnected` | 前者停止监听并告知;后者把「询问用户重开还是结束」交给模型,不擅自决定 |
|
|
31
|
+
| `session_shutdown` | 清空所有监听,不留孤儿 poll |
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pi install npm:pi-lavish
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
装完 `/reload` 或重开 pi。验证:`/lavish-status` 应显示「未监听」。
|
|
40
|
+
|
|
41
|
+
从仓库源码装(开发用):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/fookhsu/pi-lavish && cd pi-lavish
|
|
45
|
+
pi install ./
|
|
46
|
+
# 或只在当前项目:pi install ./ -l
|
|
47
|
+
# 或临时试一次:pi -e ~/workspace/pi-lavish
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 装不上时的两个坑
|
|
51
|
+
|
|
52
|
+
- **`ETARGET: No matching version found`**:npm 本地元数据缓存还没同步。用 `npm i pi-lavish@<version> --prefer-online`,或删掉 `package-lock.json` 重试。
|
|
53
|
+
- **装到了 `0.0.0-stage`**:那是 npm 给新包首发时放的公开占位版本(“Temporary package placeholder for staged publishing”),不含扩展代码。显式指定版本即可:`pi install npm:pi-lavish@0.1.1`。
|
|
54
|
+
|
|
55
|
+
## 命令与工具
|
|
56
|
+
|
|
57
|
+
| 入口 | 用途 |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `/lavish-status` | 列出正在监听的会话、连接状态、已交付轮数、最近错误 |
|
|
60
|
+
| `/lavish-watch <file>` | 手动开始监听(正常流程会自动,一般不用) |
|
|
61
|
+
| `/lavish-stop [file]` | 停止监听(省略 file 则全停) |
|
|
62
|
+
| 工具 `lavish_watch` | 同上三者,供模型自己调用(`action: start/stop/status`) |
|
|
63
|
+
|
|
64
|
+
想临时回到「前台阻塞轮询」的老姿势:`/lavish-stop <file>`,再让 agent 自己跑 `poll`。
|
|
65
|
+
|
|
66
|
+
## 与 lavish-axi 的接口
|
|
67
|
+
|
|
68
|
+
只走 HTTP API,不解析 CLI 的 TOON 输出(版本升级时字段变化会暴露而不是静默出错):
|
|
69
|
+
|
|
70
|
+
- `GET /api/poll?file=<绝对路径>`:长轮询。响应头 `Lavish-Poll-State: listening` = listener 已挂上;body 的 `status` 为 `feedback` / `waiting` / `ended` / `browser_disconnected` / `error`。
|
|
71
|
+
- 必须带 `origin: http://127.0.0.1:4387`(服务端做同源校验,缺了会被 403)。
|
|
72
|
+
- 端口默认 4387,可用 `LAVISH_AXI_PORT` 改。
|
|
73
|
+
- 会话以 **HTML 文件绝对路径** 为 key,扩展直接用绝对路径对话,不存任何 id。
|
|
74
|
+
|
|
75
|
+
## 已知边界
|
|
76
|
+
|
|
77
|
+
- **一个文件一个 listener**:扩展占住 listener 时 agent 手跑 poll 会被拦。真需要前台轮询就先 `/lavish-stop`。
|
|
78
|
+
- **只在 pi 进程存活期间监听**:pi 退出后反馈仍留在服务端队列,下次打开该文件可见。
|
|
79
|
+
- **未处理的 `--takeover`**:若外部用 `--takeover` 抢走 listener,扩展收到 `LISTENER_REPLACED` 后停止并提示,不会自动抢回。
|
|
80
|
+
- **不覆盖 `lavish-axi playbook` / `design` 的用法指导**:artifact 怎么写仍然靠作者自己的 SKILL.md 与 CLI 的 `--help`。
|
|
81
|
+
|
|
82
|
+
## 开发
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
# 类型检查(需要 @types/node 与 typescript)
|
|
86
|
+
npm i --no-save typescript @types/node
|
|
87
|
+
npx tsc --noEmit -p tsconfig.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`tsconfig.json` 把 `@earendil-works/pi-coding-agent` 与 `typebox` 指向宿主的全局安装路径;换机器时改这两条 `paths`,或改成正常装依赖的方式。
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-lavish — 让 Lavish Editor(lavish-axi)的反馈直接唤醒 pi 的新回合。
|
|
3
|
+
*
|
|
4
|
+
* 解决的问题:lavish-axi 的反馈只能通过 `lavish-axi poll` 长轮询取回。
|
|
5
|
+
* 手动跑 poll 会独占 pi 的整个 turn(人可能要在浏览器里磨五分钟)。
|
|
6
|
+
*
|
|
7
|
+
* 这个扩展把 poll 从「agent 的前台 bash」搬到「扩展的后台监听」:
|
|
8
|
+
* 1. 看到 agent 跑 `lavish-axi <file>` 打开会话 → 自动开始监听该文件;
|
|
9
|
+
* 2. 用户点 Send → 扩展收到反馈 → `pi.sendUserMessage()` 唤醒 agent 新回合;
|
|
10
|
+
* 3. 交付后暂停监听,等本轮结束(agent_settled)再自动续上,
|
|
11
|
+
* 避免用户连续发送时把几条反馈拆成多个回合。
|
|
12
|
+
*
|
|
13
|
+
* 只走 HTTP API(GET /api/poll?file=…),不解析 CLI 的 TOON 输出,
|
|
14
|
+
* 版本升级时字段变化更容易暴露而不是静默出错。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { isBashToolResult, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// 常量与工具
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PORT = 4387;
|
|
27
|
+
/** lavished 的子命令;出现在 `lavish-axi` 之后第一个非 flag 位置时视为子命令。 */
|
|
28
|
+
const SUBCOMMANDS = new Set([
|
|
29
|
+
"poll",
|
|
30
|
+
"reply",
|
|
31
|
+
"end",
|
|
32
|
+
"stop",
|
|
33
|
+
"playbook",
|
|
34
|
+
"design",
|
|
35
|
+
"setup",
|
|
36
|
+
"server",
|
|
37
|
+
"export",
|
|
38
|
+
"share",
|
|
39
|
+
"libraries",
|
|
40
|
+
"help",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** 这些子命令会创建/恢复一个 review 会话,值得开始监听。 */
|
|
44
|
+
const OPEN_LIKE = new Set(["open"]);
|
|
45
|
+
|
|
46
|
+
const MAX_PROMPT_CHARS = 2400;
|
|
47
|
+
/** 交付后等这一轮结束的上限;超时就继续监听,免得卡死。 */
|
|
48
|
+
const SETTLE_TIMEOUT_MS = 10 * 60_000;
|
|
49
|
+
|
|
50
|
+
function port(): number {
|
|
51
|
+
const raw = Number(process.env.LAVISH_AXI_PORT);
|
|
52
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PORT;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function baseUrl(): string {
|
|
56
|
+
return `http://127.0.0.1:${port()}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
60
|
+
return new Promise((done) => {
|
|
61
|
+
if (signal.aborted) return done();
|
|
62
|
+
const timer = setTimeout(finish, ms);
|
|
63
|
+
function finish() {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
signal.removeEventListener("abort", finish);
|
|
66
|
+
done();
|
|
67
|
+
}
|
|
68
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 粗糙但够用的 shell 分词:只处理引号与反斜杠转义。 */
|
|
73
|
+
function tokenize(command: string): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
let current = "";
|
|
76
|
+
let quote: '"' | "'" | null = null;
|
|
77
|
+
for (let i = 0; i < command.length; i++) {
|
|
78
|
+
const ch = command[i];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (ch === quote) quote = null;
|
|
81
|
+
else if (ch === "\\" && quote === '"' && i + 1 < command.length) current += command[++i];
|
|
82
|
+
else current += ch;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === '"' || ch === "'") {
|
|
86
|
+
quote = ch;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === "\\" && i + 1 < command.length) {
|
|
90
|
+
current += command[++i];
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/\s/.test(ch)) {
|
|
94
|
+
if (current) out.push(current);
|
|
95
|
+
current = "";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
current += ch;
|
|
99
|
+
}
|
|
100
|
+
if (current) out.push(current);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface LavishInvocation {
|
|
105
|
+
sub: string;
|
|
106
|
+
file?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 从一条 bash 命令里认出 lavish-axi 调用。认不出返回 null。 */
|
|
110
|
+
function parseInvocation(command: string): LavishInvocation | null {
|
|
111
|
+
if (!/lavish-axi/.test(command)) return null;
|
|
112
|
+
const tokens = tokenize(command);
|
|
113
|
+
const index = tokens.findIndex((token) => /(^|[\\/])lavish-axi$/.test(token));
|
|
114
|
+
if (index === -1) return null;
|
|
115
|
+
|
|
116
|
+
const rest = tokens.slice(index + 1);
|
|
117
|
+
const positional = rest.filter((token) => !token.startsWith("-"));
|
|
118
|
+
const flags = rest.filter((token) => token.startsWith("-"));
|
|
119
|
+
if (positional.length === 0) {
|
|
120
|
+
if (flags.includes("--help") || flags.includes("-h")) return { sub: "help" };
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [first, ...others] = positional;
|
|
125
|
+
if (SUBCOMMANDS.has(first)) {
|
|
126
|
+
return { sub: first, file: others[0] };
|
|
127
|
+
}
|
|
128
|
+
// `lavish-axi <html-file>` = 打开/恢复会话
|
|
129
|
+
return { sub: "open", file: first };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function absolutize(file: string, cwd: string): string {
|
|
133
|
+
return resolve(cwd, file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// 监听状态
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
type Phase = "polling" | "waiting-for-turn" | "ended";
|
|
141
|
+
|
|
142
|
+
interface Watcher {
|
|
143
|
+
file: string;
|
|
144
|
+
phase: Phase;
|
|
145
|
+
/** 服务端确认 listener 已挂上(响应头 lavish-poll-state: listening)。 */
|
|
146
|
+
connected: boolean;
|
|
147
|
+
/** 已经取回但还没交到模型手上的文本。 */
|
|
148
|
+
pending?: string;
|
|
149
|
+
controller: AbortController;
|
|
150
|
+
retries: number;
|
|
151
|
+
lastError?: string;
|
|
152
|
+
deliveredRounds: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// 扩展主体
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export default function (pi: ExtensionAPI) {
|
|
160
|
+
const watchers = new Map<string, Watcher>();
|
|
161
|
+
/** agent 空闲时给的 ExtensionContext,用于判断该立即发还是排队发。 */
|
|
162
|
+
let lastCtx: ExtensionContext | undefined;
|
|
163
|
+
/** agent_settled 的等待者。 */
|
|
164
|
+
const settleWaiters = new Set<() => void>();
|
|
165
|
+
|
|
166
|
+
function notify(ctx: ExtensionContext | undefined, message: string, level: "info" | "warning" | "error" = "info") {
|
|
167
|
+
ctx?.ui.notify(message, level);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function statusLine(): string {
|
|
171
|
+
if (watchers.size === 0) return "lavish: 未监听";
|
|
172
|
+
const parts = [...watchers.values()].map((w) => {
|
|
173
|
+
const state = w.connected ? "已连接" : "连接中";
|
|
174
|
+
return `${w.file} (${state}, ${w.phase})`;
|
|
175
|
+
});
|
|
176
|
+
return `lavish: ${parts.join(" · ")}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function refreshStatus(ctx: ExtensionContext | undefined) {
|
|
180
|
+
ctx?.ui.setStatus("pi-lavish", watchers.size > 0 ? statusLine() : undefined);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -- 反馈投递 ------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function flushPending(w: Watcher, ctx?: ExtensionContext) {
|
|
186
|
+
const text = w.pending;
|
|
187
|
+
if (!text) return;
|
|
188
|
+
try {
|
|
189
|
+
// 空闲时立即起新回合;正在跑就排到本轮工具跑完之后,避免打断手上的编辑。
|
|
190
|
+
if (ctx?.isIdle() ?? true) {
|
|
191
|
+
pi.sendUserMessage(text);
|
|
192
|
+
} else {
|
|
193
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
194
|
+
}
|
|
195
|
+
w.pending = undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
// 保持 pending,等下一个 agent_settled 再试。
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatPrompts(file: string, body: Record<string, unknown>): string {
|
|
202
|
+
const prompts = Array.isArray(body.prompts) ? (body.prompts as Array<Record<string, unknown>>) : [];
|
|
203
|
+
const lines: string[] = [];
|
|
204
|
+
lines.push(`[lavish] 用户在 Lavish 里发来 ${prompts.length} 条反馈(${file})。`);
|
|
205
|
+
lines.push("");
|
|
206
|
+
prompts.forEach((prompt, index) => {
|
|
207
|
+
const tag = String(prompt.tag ?? "chat");
|
|
208
|
+
const selector = String(prompt.selector ?? "").trim();
|
|
209
|
+
const text = String(prompt.text ?? "").replace(/\r/g, "").trim();
|
|
210
|
+
const head = selector ? `[${tag}] ${selector}` : `[${tag}]`;
|
|
211
|
+
lines.push(`${index + 1}) ${head}`);
|
|
212
|
+
if (text) {
|
|
213
|
+
const clipped =
|
|
214
|
+
text.length > MAX_PROMPT_CHARS ? `${text.slice(0, MAX_PROMPT_CHARS)}\n…(已截断)` : text;
|
|
215
|
+
lines.push(
|
|
216
|
+
clipped
|
|
217
|
+
.split("\n")
|
|
218
|
+
.map((line) => ` ${line}`)
|
|
219
|
+
.join("\n"),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
// 白板反馈带本地文件,指出来即可,不要内联进来。
|
|
223
|
+
for (const key of ["scenePath", "previewPath"]) {
|
|
224
|
+
const value = prompt[key];
|
|
225
|
+
if (typeof value === "string" && value.trim()) lines.push(` ${key}: ${value}`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const failures = Array.isArray(body.artifact_failures) ? body.artifact_failures : [];
|
|
230
|
+
if (failures.length > 0) {
|
|
231
|
+
lines.push("");
|
|
232
|
+
lines.push(`⚠ artifact_failures(review 面本身可能打不开):${JSON.stringify(failures).slice(0, 1200)}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push("下一步:直接改这个 HTML 文件;改完提交回执——");
|
|
237
|
+
lines.push(` npx -y lavish-axi reply ${JSON.stringify(file)} --agent-reply "一句话说明改了什么"`);
|
|
238
|
+
lines.push("不要手动跑 poll:监听由 pi-lavish 扩展接管,提交回执后会自动续上。");
|
|
239
|
+
return lines.join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handlePollBody(w: Watcher, body: Record<string, unknown>, ctx: ExtensionContext | undefined) {
|
|
243
|
+
const status = String(body.status ?? "");
|
|
244
|
+
const ended = Boolean(body.session_ended) || status === "ended";
|
|
245
|
+
|
|
246
|
+
if (status === "feedback") {
|
|
247
|
+
w.deliveredRounds += 1;
|
|
248
|
+
w.pending = formatPrompts(w.file, body);
|
|
249
|
+
flushPending(w, ctx);
|
|
250
|
+
|
|
251
|
+
if (ended) {
|
|
252
|
+
w.phase = "ended";
|
|
253
|
+
notify(ctx, `lavish: ${w.file} 会话已结束(${String(body.ended_by ?? "user")})`);
|
|
254
|
+
} else {
|
|
255
|
+
w.phase = "waiting-for-turn";
|
|
256
|
+
}
|
|
257
|
+
refreshStatus(ctx);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (status === "browser_disconnected") {
|
|
262
|
+
w.pending =
|
|
263
|
+
`[lavish] ${w.file} 的 review 页面已被关闭/断开(超过重连宽限期)。会话仍可恢复:` +
|
|
264
|
+
`询问用户是重新打开(npx -y lavish-axi ${JSON.stringify(w.file)})还是结束会话,不要擅自决定。`;
|
|
265
|
+
flushPending(w, ctx);
|
|
266
|
+
w.phase = "waiting-for-turn";
|
|
267
|
+
refreshStatus(ctx);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (status === "ended") {
|
|
272
|
+
w.phase = "ended";
|
|
273
|
+
notify(ctx, `lavish: ${w.file} 会话已结束`);
|
|
274
|
+
refreshStatus(ctx);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (status === "missing") {
|
|
279
|
+
// 会话还没被 CLI 创建(用户可能关掉了)——退避重试,不报错打扰人。
|
|
280
|
+
w.lastError = "会话不存在(尚未 lavish-axi <file> 打开)";
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// status === "waiting" / 未知:没有反馈,继续等。
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// -- 轮询循环 ------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async function pollOnce(file: string, signal: AbortSignal) {
|
|
290
|
+
const url = `${baseUrl()}/api/poll?file=${encodeURIComponent(file)}`;
|
|
291
|
+
const response = await fetch(url, {
|
|
292
|
+
headers: {
|
|
293
|
+
// 服务端的入队/轮询都做同源校验;这里显式声明来源。
|
|
294
|
+
origin: baseUrl(),
|
|
295
|
+
accept: "application/json",
|
|
296
|
+
},
|
|
297
|
+
signal,
|
|
298
|
+
});
|
|
299
|
+
const listening = response.headers.get("lavish-poll-state") === "listening";
|
|
300
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
301
|
+
return { listening, body };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function runLoop(w: Watcher) {
|
|
305
|
+
const { signal } = w.controller;
|
|
306
|
+
while (!signal.aborted) {
|
|
307
|
+
try {
|
|
308
|
+
const { listening, body } = await pollOnce(w.file, signal);
|
|
309
|
+
if (listening) {
|
|
310
|
+
w.connected = true;
|
|
311
|
+
w.retries = 0;
|
|
312
|
+
w.lastError = undefined;
|
|
313
|
+
refreshStatus(lastCtx);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (body.status === "error") {
|
|
317
|
+
const code = String(body.code ?? "");
|
|
318
|
+
if (code === "LISTENER_ACTIVE") {
|
|
319
|
+
// 别人(例如 agent 手跑的 poll)已经占着 listener。退让,不抢。
|
|
320
|
+
w.connected = false;
|
|
321
|
+
refreshStatus(lastCtx);
|
|
322
|
+
await sleep(5000, signal);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (code === "LISTENER_REPLACED") {
|
|
326
|
+
notify(lastCtx, `lavish: ${w.file} 的监听被另一处 poll 接管,停止监听`, "warning");
|
|
327
|
+
w.phase = "ended";
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
w.lastError = `${code}: ${String(body.error ?? "")}`;
|
|
331
|
+
await sleep(3000, signal);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await handlePollBody(w, body, lastCtx);
|
|
336
|
+
if (w.phase === "ended") return;
|
|
337
|
+
|
|
338
|
+
// 交付完先停手,等这一轮改完(agent_settled)再续上,
|
|
339
|
+
// 这样用户连发几条反馈不会拆成好几个回合;超时兜底,不会永久停摆。
|
|
340
|
+
await new Promise<void>((done) => {
|
|
341
|
+
const waiter = () => {
|
|
342
|
+
settleWaiters.delete(waiter);
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
done();
|
|
345
|
+
};
|
|
346
|
+
const timer = setTimeout(waiter, SETTLE_TIMEOUT_MS);
|
|
347
|
+
settleWaiters.add(waiter);
|
|
348
|
+
signal.addEventListener("abort", waiter, { once: true });
|
|
349
|
+
});
|
|
350
|
+
if (signal.aborted) return;
|
|
351
|
+
w.phase = "polling";
|
|
352
|
+
refreshStatus(lastCtx);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (signal.aborted) return;
|
|
355
|
+
w.connected = false;
|
|
356
|
+
w.lastError = error instanceof Error ? error.message : String(error);
|
|
357
|
+
refreshStatus(lastCtx);
|
|
358
|
+
const backoff = Math.min(30_000, 1000 * 2 ** Math.min(w.retries, 5));
|
|
359
|
+
w.retries += 1;
|
|
360
|
+
await sleep(backoff, signal);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- 监听生命周期 --------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function ensureWatcher(file: string, ctx?: ExtensionContext): Watcher {
|
|
368
|
+
const existing = watchers.get(file);
|
|
369
|
+
if (existing && existing.phase !== "ended") return existing;
|
|
370
|
+
if (existing) {
|
|
371
|
+
// 上一个会话结束了(用户 Send & End 或主动 end),重新打开时换一个新监听。
|
|
372
|
+
existing.controller.abort();
|
|
373
|
+
watchers.delete(file);
|
|
374
|
+
}
|
|
375
|
+
const watcher: Watcher = {
|
|
376
|
+
file,
|
|
377
|
+
phase: "polling",
|
|
378
|
+
connected: false,
|
|
379
|
+
controller: new AbortController(),
|
|
380
|
+
retries: 0,
|
|
381
|
+
deliveredRounds: 0,
|
|
382
|
+
};
|
|
383
|
+
watchers.set(file, watcher);
|
|
384
|
+
void runLoop(watcher);
|
|
385
|
+
refreshStatus(ctx);
|
|
386
|
+
return watcher;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function stopWatcher(file: string, ctx?: ExtensionContext) {
|
|
390
|
+
const watcher = watchers.get(file);
|
|
391
|
+
if (!watcher) return false;
|
|
392
|
+
watcher.controller.abort();
|
|
393
|
+
watchers.delete(file);
|
|
394
|
+
refreshStatus(ctx);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function stopAll() {
|
|
399
|
+
for (const watcher of watchers.values()) watcher.controller.abort();
|
|
400
|
+
watchers.clear();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 这个文件的 poll 是否已由本扩展接管(决定要不要拦住 agent 手跑的 poll)。 */
|
|
404
|
+
function ownedWatcher(file: string): Watcher | undefined {
|
|
405
|
+
const watcher = watchers.get(file);
|
|
406
|
+
return watcher && watcher.phase !== "ended" ? watcher : undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// -- 事件接线 ------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
412
|
+
lastCtx = ctx;
|
|
413
|
+
refreshStatus(ctx);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
417
|
+
lastCtx = ctx;
|
|
418
|
+
for (const waiter of [...settleWaiters]) waiter();
|
|
419
|
+
for (const watcher of watchers.values()) flushPending(watcher, ctx);
|
|
420
|
+
refreshStatus(ctx);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
pi.on("session_shutdown", async () => {
|
|
424
|
+
stopAll();
|
|
425
|
+
settleWaiters.clear();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/** 1) 拦住手动 poll:一个文件只能有一个 listener,扩展在监听时不必也不该手跑。 */
|
|
429
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
430
|
+
lastCtx = ctx;
|
|
431
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
432
|
+
const invocation = parseInvocation(event.input.command);
|
|
433
|
+
if (!invocation?.file || invocation.sub !== "poll") return;
|
|
434
|
+
|
|
435
|
+
const file = absolutize(invocation.file, ctx.cwd);
|
|
436
|
+
// 还没接管就先接管,再拦:一个文件只能有一个 listener。
|
|
437
|
+
const watcher = ownedWatcher(file) ?? ensureWatcher(file, ctx);
|
|
438
|
+
const state = watcher.connected ? "已连接" : `连接中${watcher.lastError ? `(最近错误:${watcher.lastError})` : ""}`;
|
|
439
|
+
return {
|
|
440
|
+
block: true,
|
|
441
|
+
reason:
|
|
442
|
+
`pi-lavish 扩展正在监听 ${file}(${state}),不要手动 poll —— ` +
|
|
443
|
+
`同一文件只能有一个 listener,手跑会拿到 LISTENER_ACTIVE 或把对方的 poll 顶掉。\n` +
|
|
444
|
+
`现在直接结束本回合即可:用户点 Send 时,反馈会自动作为新消息唤醒你。\n` +
|
|
445
|
+
`改完产物用 \`npx -y lavish-axi reply ${file} --agent-reply "..."\` 提交回执,监听会自动续上。\n` +
|
|
446
|
+
`确实需要前台阻塞轮询时,先让别人执行 /lavish-stop ${file} 停掉扩展监听,再重跑 poll。`,
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/** 2) 看到 agent 打开了会话 → 自动开始监听。 */
|
|
451
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
452
|
+
lastCtx = ctx;
|
|
453
|
+
if (!isBashToolResult(event)) return;
|
|
454
|
+
if (event.isError) return;
|
|
455
|
+
if (typeof event.input?.command !== "string") return;
|
|
456
|
+
|
|
457
|
+
const invocation = parseInvocation(event.input.command);
|
|
458
|
+
if (!invocation?.file || !OPEN_LIKE.has(invocation.sub)) return;
|
|
459
|
+
|
|
460
|
+
// 只在 CLI 真的报出了会话时接管(`status: opened` 或恢复成功)。
|
|
461
|
+
const output = event.content
|
|
462
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
463
|
+
.map((part) => part.text)
|
|
464
|
+
.join("\n");
|
|
465
|
+
if (!/lavish|lavish-axi/i.test(output)) return;
|
|
466
|
+
if (!/"(opened|resumed)"/.test(output) && !/status:\s*(opened|resumed)/.test(output)) return;
|
|
467
|
+
|
|
468
|
+
const file = absolutize(invocation.file, ctx.cwd);
|
|
469
|
+
ensureWatcher(file, ctx);
|
|
470
|
+
notify(ctx, `lavish: 已接管 ${file} 的反馈监听(用户点 Send 时自动唤醒)`);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/** 3) 提交回执(reply / poll --agent-reply)后立刻续上监听。 */
|
|
474
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
475
|
+
lastCtx = ctx;
|
|
476
|
+
if (!isBashToolResult(event)) return;
|
|
477
|
+
if (event.isError) return;
|
|
478
|
+
if (typeof event.input?.command !== "string") return;
|
|
479
|
+
const invocation = parseInvocation(event.input.command);
|
|
480
|
+
if (!invocation?.file) return;
|
|
481
|
+
if (invocation.sub !== "reply" && invocation.sub !== "end") return;
|
|
482
|
+
|
|
483
|
+
const file = absolutize(invocation.file, ctx.cwd);
|
|
484
|
+
if (invocation.sub === "end") {
|
|
485
|
+
stopWatcher(file, ctx);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const watcher = watchers.get(file);
|
|
489
|
+
if (watcher && watcher.phase === "waiting-for-turn") {
|
|
490
|
+
// 立即让等待者返回,不必等 agent_settled。
|
|
491
|
+
for (const waiter of [...settleWaiters]) waiter();
|
|
492
|
+
}
|
|
493
|
+
refreshStatus(ctx);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// -- 命令与工具 ----------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
pi.registerCommand("lavish-status", {
|
|
499
|
+
description: "显示 pi-lavish 正在监听哪些 Lavish 会话",
|
|
500
|
+
handler: async (_args, ctx) => {
|
|
501
|
+
if (watchers.size === 0) {
|
|
502
|
+
ctx.ui.notify("pi-lavish: 当前没有在监听任何会话", "info");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const rows = [...watchers.values()].map((w) => {
|
|
506
|
+
const bits = [w.connected ? "已连接" : "连接中", w.phase, `已交付 ${w.deliveredRounds} 轮`];
|
|
507
|
+
if (w.lastError) bits.push(`err=${w.lastError}`);
|
|
508
|
+
return `${w.file} — ${bits.join(", ")}`;
|
|
509
|
+
});
|
|
510
|
+
ctx.ui.notify(`pi-lavish:\n${rows.join("\n")}`, "info");
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
pi.registerCommand("lavish-watch", {
|
|
515
|
+
description: "手动开始监听某个 HTML 产物的 Lavish 反馈:/lavish-watch <file>",
|
|
516
|
+
handler: async (args, ctx) => {
|
|
517
|
+
const file = absolutize(args.trim(), ctx.cwd);
|
|
518
|
+
if (!file) {
|
|
519
|
+
ctx.ui.notify("用法:/lavish-watch <html-file>", "warning");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
ensureWatcher(file, ctx);
|
|
523
|
+
ctx.ui.notify(`pi-lavish: 开始监听 ${file}`, "info");
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
pi.registerCommand("lavish-stop", {
|
|
528
|
+
description: "停止监听:/lavish-stop [file](省略则全部停止)",
|
|
529
|
+
handler: async (args, ctx) => {
|
|
530
|
+
const raw = args.trim();
|
|
531
|
+
if (!raw) {
|
|
532
|
+
const count = watchers.size;
|
|
533
|
+
stopAll();
|
|
534
|
+
refreshStatus(ctx);
|
|
535
|
+
ctx.ui.notify(`pi-lavish: 已停止 ${count} 个监听`, "info");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const file = absolutize(raw, ctx.cwd);
|
|
539
|
+
const stopped = stopWatcher(file, ctx);
|
|
540
|
+
ctx.ui.notify(stopped ? `pi-lavish: 已停止监听 ${file}` : `pi-lavish: 没有在监听 ${file}`, "info");
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
pi.registerTool({
|
|
545
|
+
name: "lavish_watch",
|
|
546
|
+
label: "Lavish watch",
|
|
547
|
+
description:
|
|
548
|
+
"管理 pi-lavish 对某个 HTML 产物的反馈监听。start: 开始后台长轮询(用户点 Send 时自动唤醒新回合);" +
|
|
549
|
+
"stop: 停止;status: 查看状态。用 `lavish-axi <file>` 打开会话后会自动监听,通常不需要手动调用。",
|
|
550
|
+
parameters: Type.Object({
|
|
551
|
+
action: Type.Union([Type.Literal("start"), Type.Literal("stop"), Type.Literal("status")], {
|
|
552
|
+
description: "start / stop / status",
|
|
553
|
+
}),
|
|
554
|
+
file: Type.Optional(Type.String({ description: "HTML 产物路径(start/stop 必填)" })),
|
|
555
|
+
}),
|
|
556
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
557
|
+
const action = params.action;
|
|
558
|
+
if (action === "status") {
|
|
559
|
+
if (watchers.size === 0) return { content: [{ type: "text", text: "pi-lavish: 未监听任何会话" }], details: {} };
|
|
560
|
+
const rows = [...watchers.values()].map(
|
|
561
|
+
(w) =>
|
|
562
|
+
`${w.file} | ${w.connected ? "连接中" : "未连接"} | ${w.phase} | rounds=${w.deliveredRounds}${
|
|
563
|
+
w.lastError ? ` | err=${w.lastError}` : ""
|
|
564
|
+
}`,
|
|
565
|
+
);
|
|
566
|
+
return { content: [{ type: "text", text: rows.join("\n") }], details: {} };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const raw = params.file?.trim();
|
|
570
|
+
if (!raw) {
|
|
571
|
+
return {
|
|
572
|
+
content: [{ type: "text", text: `lavish_watch ${action} 需要 file 参数` }],
|
|
573
|
+
isError: true,
|
|
574
|
+
details: {},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const file = absolutize(raw, ctx.cwd);
|
|
578
|
+
|
|
579
|
+
if (action === "start") {
|
|
580
|
+
const watcher = ensureWatcher(file, ctx);
|
|
581
|
+
return {
|
|
582
|
+
content: [
|
|
583
|
+
{
|
|
584
|
+
type: "text",
|
|
585
|
+
text:
|
|
586
|
+
`已开始监听 ${file}(${watcher.connected ? "已连接" : "连接中"})。` +
|
|
587
|
+
"不要手动 poll;用户点 Send 时反馈会作为新消息唤醒你。",
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
details: {},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const stopped = stopWatcher(file, ctx);
|
|
595
|
+
return {
|
|
596
|
+
content: [{ type: "text", text: stopped ? `已停止监听 ${file}` : `没有在监听 ${file}` }],
|
|
597
|
+
details: {},
|
|
598
|
+
};
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lavish",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lavish Editor (lavish-axi) integration for pi: background feedback polling that wakes a fresh turn instead of blocking the running one.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "fookhsu",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/fookhsu/pi-lavish.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/fookhsu/pi-lavish#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/fookhsu/pi-lavish/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"lavish",
|
|
20
|
+
"lavish-axi",
|
|
21
|
+
"html",
|
|
22
|
+
"review",
|
|
23
|
+
"extension"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
39
|
+
}
|
|
40
|
+
}
|