nx-kv 0.2.1 → 0.3.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.
@@ -0,0 +1,598 @@
1
+ // 实时任务输入 service —— 按「工作目录(cwd)」划分的主题(topic)+ 主题任务队列 +
2
+ // 阻塞等待 + 内嵌弹窗。
3
+ //
4
+ // 解决的问题:agent 在一个对话里需要用户继续给任务时,不必结束本轮、等用户去
5
+ // 原始输入框打字,而是把 `nx-kv sessiontask wait --cwd "<工作目录>" --summary "<当前现状>"`
6
+ // 当作 **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板回填,
7
+ // 命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
8
+ //
9
+ // 关键设计:
10
+ // 1. **工作目录即主题,主题拥有队列。** wait 传入工作目录(默认进程 cwd),归一化后
11
+ // 作为主题 key;同一目录的多次 wait(即便现状措辞不同)共用同一个任务队列,
12
+ // 不同目录 → 不同主题 → 队列互不串。任务可在一个主题队列里逐条堆积,agent 按序领取。
13
+ // 2. **现状总结只作状态展示。** --summary 是 agent 当前状态(人读),每次 wait 刷新,
14
+ // 不再参与队列分桶;用户据此一眼看清 agent 做到哪、卡在哪。
15
+ // 3. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时起的
16
+ // http 服务器直出,未登录也能用。
17
+ // 4. **排队语义。** 用户可提前回填:主题已有 pending 时 wait 立即取走、不开窗;
18
+ // 没有 pending 才开窗阻塞。
19
+ // 5. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
20
+ // exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
21
+ //
22
+ // 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
23
+ // serve 的面板页)时,wait 用轮询兜底(见 POLL_MS)。
24
+ import http from 'node:http';
25
+ import crypto from 'node:crypto';
26
+ import fsp from 'node:fs/promises';
27
+ import { basename, dirname, join, resolve } from 'node:path';
28
+ import { APP_DIR } from '../../core/paths.js';
29
+ import { badInput } from '../../core/errors.js';
30
+ import { openBrowser } from '../../core/open.js';
31
+ import { renderPopupPage } from './popup.js';
32
+
33
+ export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
34
+ const POLL_MS = 500; // 跨进程兜底轮询间隔
35
+ export const DEFAULT_TIMEOUT_SEC = 180; // 推荐阻塞时长(3 分钟)
36
+ export const MAX_TIMEOUT_SEC = 600; // 阻塞时长上限(10 分钟)
37
+ export const MAX_SUMMARY = 2000; // 现状(状态)长度上限(原 200,已放宽)
38
+ export const MAX_TEXT = 20_000; // 单条任务内容长度上限
39
+
40
+ // ─── 存储位置:可环境变量覆盖(测试隔离) ───────────────────────────
41
+
42
+ export function storePath() {
43
+ return process.env.NX_KV_SESSIONTASK_STORE || join(APP_DIR, 'sessiontasks.json');
44
+ }
45
+
46
+ // ─── 工作目录 → 主题 ───────────────────────────────────────────────
47
+
48
+ /**
49
+ * 归一化工作目录并解析主题。
50
+ * @param {string} [raw] 目录路径;缺省/空 → 进程当前 cwd
51
+ * @returns {{cwd:string, key:string}}
52
+ */
53
+ export function normalizeCwd(raw) {
54
+ const trimmed = raw && String(raw).trim() ? String(raw).trim() : '';
55
+ let cwd;
56
+ try {
57
+ cwd = resolve(trimmed || process.cwd());
58
+ } catch {
59
+ cwd = process.cwd();
60
+ }
61
+ const name = basename(cwd);
62
+ return { cwd, name, key: topicKeyFor(name) };
63
+ }
64
+
65
+ // 主题以「目录名」标识:在名为 br_ct 的目录里执行 CLI → 自动领取 br_ct 主题队列的任务;
66
+ // 同名目录共用同一队列。Windows 折叠目录名大小写。
67
+ export function topicKeyFor(name) {
68
+ const fold = process.platform === 'win32' ? String(name).toLowerCase() : String(name);
69
+ return crypto.createHash('sha1').update(fold, 'utf8').digest('hex').slice(0, 16);
70
+ }
71
+
72
+ // ─── 现状(状态文本) ──────────────────────────────────────────────
73
+
74
+ // 归一化:折叠所有连续空白为单空格、去首尾。wait 必填;add 可选(仅刷新状态)。
75
+ export function normalizeSummary(raw, { optional = false } = {}) {
76
+ const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
77
+ if (!s) {
78
+ if (optional) return '';
79
+ throw badInput('缺少 --summary "<当前现状总结>"(agent 当前状态,展示给用户)');
80
+ }
81
+ if (s.length > MAX_SUMMARY) {
82
+ throw badInput(`--summary 过长(≤${MAX_SUMMARY} 字,当前 ${s.length} 字)`);
83
+ }
84
+ return s;
85
+ }
86
+
87
+ // 任务正文:必填、限长
88
+ function normalizeText(raw) {
89
+ const body = String(raw ?? '').trim();
90
+ if (!body) throw badInput('任务内容不能为空');
91
+ if (body.length > MAX_TEXT) {
92
+ throw badInput(`任务内容过长(≤${MAX_TEXT} 字,当前 ${body.length} 字)`);
93
+ }
94
+ return body;
95
+ }
96
+
97
+ // ─── 时间 ───────────────────────────────────────────────────────────
98
+
99
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
100
+
101
+ export function stampNow(d = new Date()) {
102
+ return (
103
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
104
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
105
+ );
106
+ }
107
+
108
+ // ─── 存储读写(原子写 + 损坏容忍 + v1/v2 迁移) ───────────────────
109
+
110
+ function emptyStore() {
111
+ return { version: 4, settings: { timeoutSec: DEFAULT_TIMEOUT_SEC }, topics: {} };
112
+ }
113
+
114
+ // 归一化超时秒数:非法/缺省 → 推荐值;超上限截断为 10 分钟
115
+ export function clampTimeoutSec(sec) {
116
+ let n = Number(sec);
117
+ if (!Number.isFinite(n) || n <= 0) n = DEFAULT_TIMEOUT_SEC;
118
+ n = Math.floor(n);
119
+ if (n > MAX_TIMEOUT_SEC) n = MAX_TIMEOUT_SEC;
120
+ return n;
121
+ }
122
+
123
+ // 旧的等待点(v2 slot / v1 session)转主题:旧 key 来自现状摘要、无 cwd 信息,
124
+ // 故保留原 key,用现状摘要截一段作主题名、cwd 留空。
125
+ function legacyTopic(key, b) {
126
+ const summary = (b && b.summary) || key;
127
+ return {
128
+ key,
129
+ cwd: '',
130
+ name: summary.length > 40 ? summary.slice(0, 40) + '…' : summary,
131
+ summary,
132
+ createdAt: (b && b.createdAt) || stampNow(),
133
+ updatedAt: (b && b.updatedAt) || stampNow(),
134
+ waiting: !!(b && b.waiting),
135
+ waitingUntil: (b && b.waitingUntil) || '',
136
+ tasks: b && Array.isArray(b.tasks) ? b.tasks : [],
137
+ };
138
+ }
139
+
140
+ async function loadStore() {
141
+ let topics = null;
142
+ let settings = null;
143
+ try {
144
+ const raw = await fsp.readFile(storePath(), 'utf8');
145
+ const data = JSON.parse(raw);
146
+ if (data && typeof data === 'object') {
147
+ if (data.topics && typeof data.topics === 'object') {
148
+ topics = data.topics;
149
+ } else if (data.slots && typeof data.slots === 'object') {
150
+ // v2(slots 桶)→ topics
151
+ topics = {};
152
+ for (const [k, b] of Object.entries(data.slots)) topics[k] = legacyTopic(k, b);
153
+ } else if (data.sessions && typeof data.sessions === 'object') {
154
+ // v1(sessions 桶)→ topics
155
+ topics = {};
156
+ for (const [k, b] of Object.entries(data.sessions)) topics[k] = legacyTopic(k, b);
157
+ }
158
+ if (data.settings && typeof data.settings === 'object') settings = data.settings;
159
+ }
160
+ } catch {
161
+ /* 损坏/缺失 → 空 store */
162
+ }
163
+ if (!topics) return emptyStore();
164
+ return {
165
+ version: 4,
166
+ settings: { timeoutSec: clampTimeoutSec(settings && settings.timeoutSec) },
167
+ topics,
168
+ };
169
+ }
170
+
171
+ async function saveStore(data) {
172
+ const p = storePath();
173
+ await fsp.mkdir(dirname(p), { recursive: true });
174
+ const tmp = p + '.tmp';
175
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
176
+ await fsp.rename(tmp, p);
177
+ }
178
+
179
+ // 取(必要时新建)某个主题
180
+ function topicOf(store, key, cwd, name = '') {
181
+ const nm = name || (cwd ? basename(cwd) : '') || key;
182
+ let t = store.topics[key];
183
+ if (!t || !Array.isArray(t.tasks)) {
184
+ t = {
185
+ key,
186
+ cwd: cwd || '',
187
+ name: nm,
188
+ createdAt: stampNow(),
189
+ updatedAt: stampNow(),
190
+ waiting: false,
191
+ waitingUntil: '',
192
+ summary: '',
193
+ tasks: [],
194
+ };
195
+ store.topics[key] = t;
196
+ }
197
+ if (cwd) t.cwd = cwd;
198
+ if (name) t.name = name;
199
+ else if (cwd) t.name = basename(cwd) || t.name;
200
+ return t;
201
+ }
202
+
203
+ function nextTaskId(tasks) {
204
+ let max = 0;
205
+ for (const t of tasks) {
206
+ const id = Number(t.id) || 0;
207
+ if (id > max) max = id;
208
+ }
209
+ return max + 1;
210
+ }
211
+
212
+ function normalizeTask(t) {
213
+ return {
214
+ id: Number(t.id) || 0,
215
+ text: String(t.text ?? ''),
216
+ status: t.status === 'consumed' ? 'consumed' : 'pending',
217
+ createdAt: String(t.createdAt ?? ''),
218
+ consumedAt: String(t.consumedAt ?? ''),
219
+ };
220
+ }
221
+
222
+ function topicView(t) {
223
+ const tasks = t.tasks.map(normalizeTask);
224
+ return {
225
+ key: t.key,
226
+ cwd: t.cwd || '',
227
+ name: t.name || '',
228
+ summary: t.summary || '',
229
+ waiting: !!t.waiting,
230
+ waitingUntil: t.waitingUntil || '',
231
+ createdAt: t.createdAt,
232
+ updatedAt: t.updatedAt,
233
+ pending: tasks.filter((x) => x.status !== 'consumed').length,
234
+ total: tasks.length,
235
+ tasks,
236
+ };
237
+ }
238
+
239
+ // ─── 进程内 waiter:key -> { resolve(task) } ───────────────────────
240
+
241
+ const waiters = new Map();
242
+
243
+ function markConsumed(task) {
244
+ task.status = 'consumed';
245
+ task.consumedAt = stampNow();
246
+ return task;
247
+ }
248
+
249
+ // 取走该主题第一条 pending;没有则返回 null
250
+ export async function consumeFirstPending(key) {
251
+ const store = await loadStore();
252
+ const t = store.topics[key];
253
+ if (!t) return null;
254
+ const task = t.tasks.find((x) => x.status !== 'consumed');
255
+ if (!task) return null;
256
+ markConsumed(task);
257
+ t.updatedAt = stampNow();
258
+ await saveStore(store);
259
+ return normalizeTask(task);
260
+ }
261
+
262
+ // 在 store 里把某主题置为等待/结束,并刷新现状
263
+ async function setWaiting(key, cwd, summary, waiting, deadline) {
264
+ const store = await loadStore();
265
+ const t = topicOf(store, key, cwd);
266
+ t.waiting = waiting;
267
+ t.waitingUntil = waiting && deadline ? stampNow(deadline) : '';
268
+ if (summary) t.summary = summary;
269
+ t.updatedAt = stampNow();
270
+ await saveStore(store);
271
+ }
272
+
273
+ // ─── CRUD ───────────────────────────────────────────────────────────
274
+
275
+ function settingsView(s) {
276
+ return {
277
+ timeoutSec: clampTimeoutSec(s && s.timeoutSec),
278
+ recommendedSec: DEFAULT_TIMEOUT_SEC,
279
+ maxSec: MAX_TIMEOUT_SEC,
280
+ };
281
+ }
282
+
283
+ // 总览:返回全部主题(面板据此分组展示与回填)+ 超时设置,按最近更新排序
284
+ export async function listTasks() {
285
+ const store = await loadStore();
286
+ const topics = Object.values(store.topics)
287
+ .map(topicView)
288
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
289
+ return { topics, waiting: topics.filter((t) => t.waiting).length, settings: settingsView(store.settings) };
290
+ }
291
+
292
+ export async function getSettings() {
293
+ const store = await loadStore();
294
+ return settingsView(store.settings);
295
+ }
296
+
297
+ // 配置等待超时(页面可调;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)
298
+ export async function updateSettings({ timeoutSec } = {}) {
299
+ const n = Number(timeoutSec);
300
+ if (!Number.isFinite(n) || n <= 0) {
301
+ throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
302
+ }
303
+ const store = await loadStore();
304
+ store.settings = { timeoutSec: clampTimeoutSec(n) };
305
+ await saveStore(store);
306
+ return settingsView(store.settings);
307
+ }
308
+
309
+ export async function getTask({ cwd: rawCwd, id } = {}) {
310
+ const { key, cwd } = normalizeCwd(rawCwd);
311
+ const want = Number(id);
312
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
313
+ const store = await loadStore();
314
+ const t = store.topics[key];
315
+ const task = t && t.tasks.find((x) => Number(x.id) === want);
316
+ if (!task) {
317
+ const { notFound } = await import('../../core/errors.js');
318
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
319
+ }
320
+ return normalizeTask(task);
321
+ }
322
+
323
+ /**
324
+ * 向某主题(工作目录)的队列回填任务。
325
+ * 若本进程正有 wait 在等该主题 → 直接投递并唤醒;否则入队堆积。
326
+ */
327
+ export async function addTask({ cwd: rawCwd, summary: rawSummary, text } = {}) {
328
+ const { key, cwd } = normalizeCwd(rawCwd);
329
+ const summary = normalizeSummary(rawSummary, { optional: true });
330
+ const body = normalizeText(text);
331
+
332
+ const store = await loadStore();
333
+ const t = topicOf(store, key, cwd);
334
+ const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow() });
335
+ t.tasks.push(task);
336
+ t.updatedAt = stampNow();
337
+ if (summary) t.summary = summary;
338
+
339
+ const waiter = waiters.get(key);
340
+ const delivered = !!waiter;
341
+ if (waiter) {
342
+ markConsumed(task);
343
+ waiters.delete(key);
344
+ t.waiting = false;
345
+ t.waitingUntil = '';
346
+ }
347
+
348
+ await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
349
+ if (waiter) waiter.resolve(normalizeTask(task));
350
+
351
+ return {
352
+ status: 'ok',
353
+ key,
354
+ cwd,
355
+ name: t.name,
356
+ summary: t.summary,
357
+ task: normalizeTask(task),
358
+ delivered,
359
+ queued: !delivered,
360
+ };
361
+ }
362
+
363
+ export async function updateTask({ cwd: rawCwd, id, text } = {}) {
364
+ const { key, cwd } = normalizeCwd(rawCwd);
365
+ const want = Number(id);
366
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
367
+ const body = String(text ?? '').trim();
368
+ if (!body) throw badInput('--text 不能为空');
369
+ if (body.length > MAX_TEXT) throw badInput(`任务内容过长(≤${MAX_TEXT} 字)`);
370
+
371
+ const store = await loadStore();
372
+ const t = store.topics[key];
373
+ const task = t && t.tasks.find((x) => Number(x.id) === want);
374
+ if (!task) {
375
+ const { notFound } = await import('../../core/errors.js');
376
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
377
+ }
378
+ task.text = body;
379
+ t.updatedAt = stampNow();
380
+ await saveStore(store);
381
+ return { status: 'ok', key, cwd, name: t.name, task: normalizeTask(task) };
382
+ }
383
+
384
+ export async function removeTask({ cwd: rawCwd, id } = {}) {
385
+ const { key, cwd } = normalizeCwd(rawCwd);
386
+ const want = Number(id);
387
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
388
+
389
+ const store = await loadStore();
390
+ const t = store.topics[key];
391
+ const index = t ? t.tasks.findIndex((x) => Number(x.id) === want) : -1;
392
+ if (index < 0) {
393
+ const { notFound } = await import('../../core/errors.js');
394
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
395
+ }
396
+ const [removed] = t.tasks.splice(index, 1);
397
+ t.updatedAt = stampNow();
398
+ await saveStore(store);
399
+ return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
400
+ }
401
+
402
+ // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
403
+
404
+ /**
405
+ * 针对某主题(工作目录)等待用户回填下一条任务。
406
+ * @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd
407
+ * @param {string} summary 当前现状(必填,状态展示)
408
+ * @param {number} [timeoutSec] 阻塞上限(秒);缺省 → 页面配置(推荐 180,最大 600)
409
+ * @param {number} [timeoutMs] 兼容旧参数(毫秒)
410
+ * @param {boolean} [open] 是否自动打开浏览器弹窗(测试可关)
411
+ * @param {function} [onReady] 服务器就绪回调 {addr,key,cwd,name,summary}(测试/集成钩子)
412
+ */
413
+ export async function waitForTask({
414
+ cwd: rawCwd,
415
+ summary: rawSummary,
416
+ timeoutSec,
417
+ timeoutMs,
418
+ open = true,
419
+ onReady,
420
+ } = {}) {
421
+ const { key, cwd, name } = normalizeCwd(rawCwd);
422
+ const summary = normalizeSummary(rawSummary); // 现状必填
423
+
424
+ // 超时:显式 --timeout(秒/毫秒)优先;否则用页面配置;统一夹到 1-600
425
+ let sec;
426
+ if (timeoutSec !== undefined && timeoutSec !== null) sec = timeoutSec;
427
+ else if (timeoutMs !== undefined && timeoutMs !== null) sec = Number(timeoutMs) / 1000;
428
+ else sec = (await loadStore()).settings.timeoutSec;
429
+ const tmo = clampTimeoutSec(sec) * 1000;
430
+
431
+ // 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞
432
+ const queued = await consumeFirstPending(key);
433
+ if (queued) return { status: 'ok', key, cwd, name, summary, task: queued, queued: true };
434
+
435
+ // 2. 没有 pending → 起内嵌弹窗服务器
436
+ const server = await startPopupServer(key, cwd, summary, tmo);
437
+ const addr = `http://127.0.0.1:${server.address().port}/`;
438
+
439
+ // 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
440
+ let resolveOutcome;
441
+ const outcome = new Promise((r) => {
442
+ resolveOutcome = r;
443
+ });
444
+ waiters.set(key, {
445
+ resolve: (task) => resolveOutcome({ kind: 'task', task, delivered: true }),
446
+ });
447
+
448
+ const poll = setInterval(() => {
449
+ consumeFirstPending(key)
450
+ .then((task) => {
451
+ if (task) resolveOutcome({ kind: 'task', task, delivered: false });
452
+ })
453
+ .catch(() => {
454
+ /* 瞬时读写错误,下一轮再试 */
455
+ });
456
+ }, POLL_MS);
457
+
458
+ const timer = setTimeout(() => resolveOutcome({ kind: 'timeout' }), tmo);
459
+
460
+ // 登记「等待中」(含截止时刻与现状),面板/弹窗据此显示倒计时
461
+ await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
462
+
463
+ // waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
464
+ if (typeof onReady === 'function') {
465
+ try {
466
+ await onReady({ addr, key, cwd, name, summary });
467
+ } catch {
468
+ /* 钩子异常不得影响 wait */
469
+ }
470
+ }
471
+
472
+ if (open) openBrowser(addr);
473
+
474
+ let result;
475
+ try {
476
+ const r = await outcome;
477
+ result =
478
+ r.kind === 'timeout'
479
+ ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
480
+ : { status: 'ok', key, cwd, name, summary, task: r.task, delivered: r.delivered, queued: false };
481
+ } finally {
482
+ clearInterval(poll);
483
+ clearTimeout(timer);
484
+ waiters.delete(key);
485
+ await setWaiting(key, cwd, '', false);
486
+ await stopServer(server);
487
+ }
488
+ return result;
489
+ }
490
+
491
+ // ─── 内嵌弹窗服务器 ─────────────────────────────────────────────────
492
+
493
+ function sendJson(res, status, obj) {
494
+ res.writeHead(status, {
495
+ 'content-type': 'application/json; charset=utf-8',
496
+ 'cache-control': 'no-store',
497
+ });
498
+ res.end(JSON.stringify(obj));
499
+ }
500
+
501
+ function sendHtml(res, html) {
502
+ res.writeHead(200, {
503
+ 'content-type': 'text/html; charset=utf-8',
504
+ 'cache-control': 'no-store',
505
+ });
506
+ res.end(html);
507
+ }
508
+
509
+ async function readBody(req) {
510
+ const chunks = [];
511
+ let size = 0;
512
+ for await (const c of req) {
513
+ size += c.length;
514
+ if (size > 1024 * 1024) throw new Error('请求体过大');
515
+ chunks.push(c);
516
+ }
517
+ if (!chunks.length) return {};
518
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
519
+ }
520
+
521
+ // 与主 api.js 同一套跨站防护:弹窗服务器虽只绑 127.0.0.1,浏览器里任意页面都能
522
+ // 向它发请求,非本机来源一律挡。
523
+ function originAllowed(req) {
524
+ const origin = req.headers.origin;
525
+ if (!origin) return true;
526
+ try {
527
+ const h = new URL(origin).hostname;
528
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1';
529
+ } catch {
530
+ return false;
531
+ }
532
+ }
533
+
534
+ async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
535
+ const url = new URL(req.url, 'http://127.0.0.1/');
536
+ const method = (req.method || 'GET').toUpperCase();
537
+
538
+ try {
539
+ if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
540
+ sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
541
+ return;
542
+ }
543
+
544
+ if (url.pathname === '/api/sessiontasks' && method === 'GET') {
545
+ sendJson(res, 200, { ok: true, data: await listTasks() });
546
+ return;
547
+ }
548
+
549
+ if (url.pathname === '/api/sessiontasks' && method === 'POST') {
550
+ if (!originAllowed(req)) {
551
+ sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
552
+ return;
553
+ }
554
+ const body = await readBody(req);
555
+ sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
556
+ return;
557
+ }
558
+
559
+ if (url.pathname === '/api/sessiontasks/item' && method === 'DELETE') {
560
+ if (!originAllowed(req)) {
561
+ sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
562
+ } else {
563
+ const body = await readBody(req);
564
+ sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
565
+ }
566
+ return;
567
+ }
568
+
569
+ sendJson(res, 404, { ok: false, error: '不存在: ' + method + ' ' + url.pathname });
570
+ } catch (err) {
571
+ const code = err && err.code === 'INVALID_INPUT' ? 400 : err && err.code === 'NOT_FOUND' ? 404 : 500;
572
+ sendJson(res, code, { ok: false, error: String((err && err.message) || err), code: err.code || 'INTERNAL' });
573
+ }
574
+ }
575
+
576
+ function startPopupServer(key, cwd, summary, timeoutMs) {
577
+ const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
578
+ return new Promise((resolve_, reject) => {
579
+ server.once('error', reject);
580
+ server.listen(0, '127.0.0.1', () => resolve_(server));
581
+ });
582
+ }
583
+
584
+ function stopServer(server) {
585
+ return new Promise((resolve_) => {
586
+ const force = setTimeout(() => {
587
+ try {
588
+ server.closeAllConnections();
589
+ } catch {
590
+ /* Node 版本不支持则忽略,close 仍会兜底 */
591
+ }
592
+ }, 800);
593
+ server.close(() => {
594
+ clearTimeout(force);
595
+ resolve_();
596
+ });
597
+ });
598
+ }