nx-kv 0.2.0 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +32 -4
  3. package/assets/kv-sessiontask/SKILL.md +106 -0
  4. package/assets/kv-sessiontask/references/sessiontask-workflow.md +127 -0
  5. package/package.json +5 -3
  6. package/src/index.js +1 -0
  7. package/src/modules/sessiontask/index.js +137 -0
  8. package/src/modules/sessiontask/popup.js +256 -0
  9. package/src/modules/sessiontask/service.js +476 -0
  10. package/src/modules/sessiontask/view.jsx +239 -0
  11. package/src/runtime/registry.js +2 -1
  12. package/src/web/frontend/App.jsx +1 -1
  13. package/src/web/frontend/index.html +6 -2
  14. package/src/web/frontend/public/favicon-16.png +0 -0
  15. package/src/web/frontend/public/favicon-32.png +0 -0
  16. package/src/web/frontend/public/favicon-48.png +0 -0
  17. package/src/web/frontend/public/favicon.ico +0 -0
  18. package/src/web/frontend/public/logo-rounded.png +0 -0
  19. package/src/web/frontend/public/logo.png +0 -0
  20. package/src/web/frontend/registry.js +1 -0
  21. package/src/web/public/assets/{CliHints-Bc3Me3vO.js → CliHints-CDKMmZbP.js} +1 -1
  22. package/src/web/public/assets/{index-Dv9g9jmR.js → index-DW8t26MP.js} +2 -2
  23. package/src/web/public/assets/view-CLDx6X3W.js +1 -0
  24. package/src/web/public/assets/{view-BKTVRs9U.js → view-C_qjdm2x.js} +1 -1
  25. package/src/web/public/assets/{view-Cdi5TUV1.js → view-CdtYjY9W.js} +1 -1
  26. package/src/web/public/assets/{view-B8AnDK0i.js → view-CqGxh_0X.js} +1 -1
  27. package/src/web/public/favicon-16.png +0 -0
  28. package/src/web/public/favicon-32.png +0 -0
  29. package/src/web/public/favicon-48.png +0 -0
  30. package/src/web/public/favicon.ico +0 -0
  31. package/src/web/public/index.html +7 -3
  32. package/src/web/public/logo-rounded.png +0 -0
  33. package/src/web/public/logo.png +0 -0
@@ -0,0 +1,476 @@
1
+ // 实时任务输入 service —— 按「现状总结」分散的等待点(slot)+ 阻塞等待 + 内嵌弹窗。
2
+ //
3
+ // 解决的问题:agent 在一个对话里需要用户继续给任务时,不必结束本轮、等用户去
4
+ // 原始输入框打字,而是把 `nx-kv sessiontask wait --summary "<当前现状总结>"` 当作
5
+ // **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板针对
6
+ // 该现状回填,命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
7
+ //
8
+ // 关键设计:
9
+ // 1. **现状总结即 key。** wait 必须带一句当前现状总结;相同总结 → 同一个等待点,
10
+ // 不同总结 → 不同等待点,从而把答复「分散」到各现状下。面板总览每个等待点
11
+ // 都显示现状,用户一眼看清、分别回填。
12
+ // 2. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时
13
+ // 起的 http 服务器直出,未登录也能用。
14
+ // 3. **排队语义。** 用户可提前回填:等待点已有 pending 时 wait 立即取走、不开窗;
15
+ // 没有 pending 才开窗阻塞。
16
+ // 4. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
17
+ // exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
18
+ //
19
+ // 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
20
+ // serve 的面板页)时,wait 用轮询兜底(见 POLL_MS)。
21
+ import http from 'node:http';
22
+ import crypto from 'node:crypto';
23
+ import fsp from 'node:fs/promises';
24
+ import { dirname, join } from 'node:path';
25
+ import { APP_DIR } from '../../core/paths.js';
26
+ import { badInput } from '../../core/errors.js';
27
+ import { openBrowser } from '../../core/open.js';
28
+ import { renderPopupPage } from './popup.js';
29
+
30
+ export const DEFAULT_TIMEOUT_MS = 120_000; // 阻塞默认 2 分钟
31
+ const POLL_MS = 500; // 跨进程兜底轮询间隔
32
+ const MAX_SUMMARY = 200; // 现状总结长度上限
33
+
34
+ // ─── 存储位置:可环境变量覆盖(测试隔离) ───────────────────────────
35
+
36
+ export function storePath() {
37
+ return process.env.NX_KV_SESSIONTASK_STORE || join(APP_DIR, 'sessiontasks.json');
38
+ }
39
+
40
+ // ─── 现状总结 → key ────────────────────────────────────────────────
41
+
42
+ // 归一化:折叠所有连续空白为单空格、去首尾;必填
43
+ export function normalizeSummary(raw) {
44
+ const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
45
+ if (!s) throw badInput('缺少 --summary "<当前现状总结>"(作为等待点的 key)');
46
+ if (s.length > MAX_SUMMARY) throw badInput(`--summary 过长(≤${MAX_SUMMARY} 字)`);
47
+ return s;
48
+ }
49
+
50
+ // 相同现状 → 相同 key;不同现状 → 不同 key。展示仍用现状原文,key 仅作内部标识。
51
+ export function keyForSummary(summary) {
52
+ return crypto.createHash('sha1').update(summary, 'utf8').digest('hex').slice(0, 16);
53
+ }
54
+
55
+ // ─── 时间 ───────────────────────────────────────────────────────────
56
+
57
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
58
+
59
+ export function stampNow(d = new Date()) {
60
+ return (
61
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
62
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
63
+ );
64
+ }
65
+
66
+ // ─── 存储读写(原子写 + 损坏容忍 + v1 迁移) ───────────────────────
67
+
68
+ function emptyStore() {
69
+ return { version: 2, slots: {} };
70
+ }
71
+
72
+ async function loadStore() {
73
+ try {
74
+ const raw = await fsp.readFile(storePath(), 'utf8');
75
+ const data = JSON.parse(raw);
76
+ if (data && typeof data === 'object') {
77
+ if (data.slots && typeof data.slots === 'object') return { version: 2, slots: data.slots };
78
+ // v1(sessions 桶)迁移为 slots,现状用旧 key 占位
79
+ if (data.sessions && typeof data.sessions === 'object') {
80
+ const slots = {};
81
+ for (const [k, b] of Object.entries(data.sessions)) {
82
+ slots[k] = {
83
+ key: k,
84
+ summary: k,
85
+ createdAt: (b && b.createdAt) || stampNow(),
86
+ updatedAt: stampNow(),
87
+ waiting: false,
88
+ waitingUntil: '',
89
+ tasks: b && Array.isArray(b.tasks) ? b.tasks : [],
90
+ };
91
+ }
92
+ return { version: 2, slots };
93
+ }
94
+ }
95
+ } catch {
96
+ /* 损坏/缺失 → 空 store */
97
+ }
98
+ return emptyStore();
99
+ }
100
+
101
+ async function saveStore(data) {
102
+ const p = storePath();
103
+ await fsp.mkdir(dirname(p), { recursive: true });
104
+ const tmp = p + '.tmp';
105
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
106
+ await fsp.rename(tmp, p);
107
+ }
108
+
109
+ // 取(必要时新建)某个等待点
110
+ function slotOf(store, key, summary) {
111
+ let s = store.slots[key];
112
+ if (!s || !Array.isArray(s.tasks)) {
113
+ s = {
114
+ key,
115
+ summary,
116
+ createdAt: stampNow(),
117
+ updatedAt: stampNow(),
118
+ waiting: false,
119
+ waitingUntil: '',
120
+ tasks: [],
121
+ };
122
+ store.slots[key] = s;
123
+ }
124
+ if (summary) s.summary = summary; // 同一 key 下刷新现状措辞
125
+ return s;
126
+ }
127
+
128
+ function nextTaskId(tasks) {
129
+ let max = 0;
130
+ for (const t of tasks) {
131
+ const id = Number(t.id) || 0;
132
+ if (id > max) max = id;
133
+ }
134
+ return max + 1;
135
+ }
136
+
137
+ function normalizeTask(t) {
138
+ return {
139
+ id: Number(t.id) || 0,
140
+ text: String(t.text ?? ''),
141
+ status: t.status === 'consumed' ? 'consumed' : 'pending',
142
+ createdAt: String(t.createdAt ?? ''),
143
+ consumedAt: String(t.consumedAt ?? ''),
144
+ };
145
+ }
146
+
147
+ function slotView(s) {
148
+ const tasks = s.tasks.map(normalizeTask);
149
+ return {
150
+ key: s.key,
151
+ summary: s.summary,
152
+ waiting: !!s.waiting,
153
+ waitingUntil: s.waitingUntil || '',
154
+ createdAt: s.createdAt,
155
+ updatedAt: s.updatedAt,
156
+ pending: tasks.filter((t) => t.status !== 'consumed').length,
157
+ total: tasks.length,
158
+ tasks,
159
+ };
160
+ }
161
+
162
+ // ─── 进程内 waiter:key -> { resolve(task) } ───────────────────────
163
+
164
+ const waiters = new Map();
165
+
166
+ function markConsumed(task) {
167
+ task.status = 'consumed';
168
+ task.consumedAt = stampNow();
169
+ return task;
170
+ }
171
+
172
+ // 取走该等待点第一条 pending;没有则返回 null
173
+ export async function consumeFirstPending(key) {
174
+ const store = await loadStore();
175
+ const s = store.slots[key];
176
+ if (!s) return null;
177
+ const task = s.tasks.find((t) => t.status !== 'consumed');
178
+ if (!task) return null;
179
+ markConsumed(task);
180
+ s.updatedAt = stampNow();
181
+ await saveStore(store);
182
+ return normalizeTask(task);
183
+ }
184
+
185
+ // 在 store 里把某等待点置为等待/结束
186
+ async function setWaiting(key, summary, waiting, deadline) {
187
+ const store = await loadStore();
188
+ const s = slotOf(store, key, summary);
189
+ s.waiting = waiting;
190
+ s.waitingUntil = waiting && deadline ? stampNow(deadline) : '';
191
+ s.updatedAt = stampNow();
192
+ await saveStore(store);
193
+ }
194
+
195
+ // ─── CRUD ───────────────────────────────────────────────────────────
196
+
197
+ // 总览:返回全部等待点(面板据此分散展示与回填),按最近更新排序
198
+ export async function listTasks() {
199
+ const store = await loadStore();
200
+ const slots = Object.values(store.slots)
201
+ .map(slotView)
202
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
203
+ return { slots, waiting: slots.filter((s) => s.waiting).length };
204
+ }
205
+
206
+ export async function getTask({ summary: rawSummary, id } = {}) {
207
+ const summary = normalizeSummary(rawSummary);
208
+ const key = keyForSummary(summary);
209
+ const want = Number(id);
210
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
211
+ const store = await loadStore();
212
+ const s = store.slots[key];
213
+ const task = s && s.tasks.find((t) => Number(t.id) === want);
214
+ if (!task) {
215
+ const { notFound } = await import('../../core/errors.js');
216
+ throw notFound(`现状「${summary}」里没有 #${want} 任务`);
217
+ }
218
+ return normalizeTask(task);
219
+ }
220
+
221
+ /** 针对某现状回填任务。若本进程正有 wait 在等该 key → 直接投递并唤醒;否则排队。 */
222
+ export async function addTask({ summary: rawSummary, text } = {}) {
223
+ const summary = normalizeSummary(rawSummary);
224
+ const key = keyForSummary(summary);
225
+ const body = String(text ?? '').trim();
226
+ if (!body) throw badInput('任务内容不能为空');
227
+
228
+ const store = await loadStore();
229
+ const s = slotOf(store, key, summary);
230
+ const task = normalizeTask({ id: nextTaskId(s.tasks), text: body, status: 'pending', createdAt: stampNow() });
231
+ s.tasks.push(task);
232
+ s.updatedAt = stampNow();
233
+
234
+ const waiter = waiters.get(key);
235
+ const delivered = !!waiter;
236
+ if (waiter) {
237
+ markConsumed(task);
238
+ waiters.delete(key);
239
+ s.waiting = false;
240
+ s.waitingUntil = '';
241
+ }
242
+
243
+ await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
244
+ if (waiter) waiter.resolve(normalizeTask(task));
245
+
246
+ return { status: 'ok', key, summary, task: normalizeTask(task), delivered, queued: !delivered };
247
+ }
248
+
249
+ export async function updateTask({ summary: rawSummary, id, text } = {}) {
250
+ const summary = normalizeSummary(rawSummary);
251
+ const key = keyForSummary(summary);
252
+ const want = Number(id);
253
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
254
+ const body = String(text ?? '').trim();
255
+ if (!body) throw badInput('--text 不能为空');
256
+
257
+ const store = await loadStore();
258
+ const s = store.slots[key];
259
+ const task = s && s.tasks.find((t) => Number(t.id) === want);
260
+ if (!task) {
261
+ const { notFound } = await import('../../core/errors.js');
262
+ throw notFound(`现状「${summary}」里没有 #${want} 任务`);
263
+ }
264
+ task.text = body;
265
+ s.updatedAt = stampNow();
266
+ await saveStore(store);
267
+ return { status: 'ok', key, summary, task: normalizeTask(task) };
268
+ }
269
+
270
+ export async function removeTask({ summary: rawSummary, id } = {}) {
271
+ const summary = normalizeSummary(rawSummary);
272
+ const key = keyForSummary(summary);
273
+ const want = Number(id);
274
+ if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
275
+
276
+ const store = await loadStore();
277
+ const s = store.slots[key];
278
+ const index = s ? s.tasks.findIndex((t) => Number(t.id) === want) : -1;
279
+ if (index < 0) {
280
+ const { notFound } = await import('../../core/errors.js');
281
+ throw notFound(`现状「${summary}」里没有 #${want} 任务`);
282
+ }
283
+ const [removed] = s.tasks.splice(index, 1);
284
+ s.updatedAt = stampNow();
285
+ await saveStore(store);
286
+ return { status: 'ok', key, summary, removed: normalizeTask(removed) };
287
+ }
288
+
289
+ // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
290
+
291
+ /**
292
+ * 针对当前现状等待用户回填下一条任务。
293
+ * @param {string} summary 当前现状总结(必填,作为等待点 key)
294
+ * @param {number} timeoutMs 阻塞上限,默认 120000(2 分钟)
295
+ * @param {boolean} open 是否自动打开浏览器弹窗(测试可关)
296
+ * @param {function} onReady 服务器就绪回调 {addr,key,summary}(测试/集成钩子)
297
+ */
298
+ export async function waitForTask({
299
+ summary: rawSummary,
300
+ timeoutMs = DEFAULT_TIMEOUT_MS,
301
+ open = true,
302
+ onReady,
303
+ } = {}) {
304
+ const summary = normalizeSummary(rawSummary);
305
+ const key = keyForSummary(summary);
306
+ let tmo = Number(timeoutMs);
307
+ if (!Number.isFinite(tmo) || tmo <= 0) tmo = DEFAULT_TIMEOUT_MS;
308
+
309
+ // 1. 等待点已有 pending → 立即取走,不开窗、不阻塞
310
+ const queued = await consumeFirstPending(key);
311
+ if (queued) return { status: 'ok', key, summary, task: queued, queued: true };
312
+
313
+ // 2. 没有 pending → 起内嵌弹窗服务器
314
+ const server = await startPopupServer(key, summary, tmo);
315
+ const addr = `http://127.0.0.1:${server.address().port}/`;
316
+
317
+ // 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
318
+ let resolveOutcome;
319
+ const outcome = new Promise((r) => {
320
+ resolveOutcome = r;
321
+ });
322
+ waiters.set(key, {
323
+ resolve: (task) => resolveOutcome({ kind: 'task', task, delivered: true }),
324
+ });
325
+
326
+ const poll = setInterval(() => {
327
+ consumeFirstPending(key)
328
+ .then((t) => {
329
+ if (t) resolveOutcome({ kind: 'task', task: t, delivered: false });
330
+ })
331
+ .catch(() => {
332
+ /* 瞬时读写错误,下一轮再试 */
333
+ });
334
+ }, POLL_MS);
335
+
336
+ const timer = setTimeout(() => resolveOutcome({ kind: 'timeout' }), tmo);
337
+
338
+ // 登记「等待中」(含截止时刻),面板/弹窗据此显示倒计时
339
+ await setWaiting(key, summary, true, new Date(Date.now() + tmo));
340
+
341
+ // waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
342
+ if (typeof onReady === 'function') {
343
+ try {
344
+ await onReady({ addr, key, summary });
345
+ } catch {
346
+ /* 钩子异常不得影响 wait */
347
+ }
348
+ }
349
+
350
+ if (open) openBrowser(addr);
351
+
352
+ let result;
353
+ try {
354
+ const r = await outcome;
355
+ result =
356
+ r.kind === 'timeout'
357
+ ? { status: 'timeout', key, summary, timeoutMs: tmo }
358
+ : { status: 'ok', key, summary, task: r.task, delivered: r.delivered, queued: false };
359
+ } finally {
360
+ clearInterval(poll);
361
+ clearTimeout(timer);
362
+ waiters.delete(key);
363
+ await setWaiting(key, summary, false);
364
+ await stopServer(server);
365
+ }
366
+ return result;
367
+ }
368
+
369
+ // ─── 内嵌弹窗服务器 ─────────────────────────────────────────────────
370
+
371
+ function sendJson(res, status, obj) {
372
+ res.writeHead(status, {
373
+ 'content-type': 'application/json; charset=utf-8',
374
+ 'cache-control': 'no-store',
375
+ });
376
+ res.end(JSON.stringify(obj));
377
+ }
378
+
379
+ function sendHtml(res, html) {
380
+ res.writeHead(200, {
381
+ 'content-type': 'text/html; charset=utf-8',
382
+ 'cache-control': 'no-store',
383
+ });
384
+ res.end(html);
385
+ }
386
+
387
+ async function readBody(req) {
388
+ const chunks = [];
389
+ let size = 0;
390
+ for await (const c of req) {
391
+ size += c.length;
392
+ if (size > 1024 * 1024) throw new Error('请求体过大');
393
+ chunks.push(c);
394
+ }
395
+ if (!chunks.length) return {};
396
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
397
+ }
398
+
399
+ // 与主 api.js 同一套跨站防护:弹窗服务器虽只绑 127.0.0.1,浏览器里任意页面都能
400
+ // 向它发请求,非本机来源一律挡。
401
+ function originAllowed(req) {
402
+ const origin = req.headers.origin;
403
+ if (!origin) return true;
404
+ try {
405
+ const h = new URL(origin).hostname;
406
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1';
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+
412
+ async function handlePopup(req, res, key, summary, timeoutMs) {
413
+ const url = new URL(req.url, 'http://127.0.0.1/');
414
+ const method = (req.method || 'GET').toUpperCase();
415
+
416
+ try {
417
+ if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
418
+ sendHtml(res, renderPopupPage({ key, summary, timeoutMs }));
419
+ return;
420
+ }
421
+
422
+ if (url.pathname === '/api/sessiontasks' && method === 'GET') {
423
+ sendJson(res, 200, { ok: true, data: await listTasks() });
424
+ return;
425
+ }
426
+
427
+ if (url.pathname === '/api/sessiontasks' && method === 'POST') {
428
+ if (!originAllowed(req)) {
429
+ sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
430
+ return;
431
+ }
432
+ const body = await readBody(req);
433
+ sendJson(res, 200, { ok: true, data: await addTask({ summary, text: body.text }) });
434
+ return;
435
+ }
436
+
437
+ if (url.pathname === '/api/sessiontasks/item' && method === 'DELETE') {
438
+ if (!originAllowed(req)) {
439
+ sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
440
+ } else {
441
+ const body = await readBody(req);
442
+ sendJson(res, 200, { ok: true, data: await removeTask({ summary, id: body.id }) });
443
+ }
444
+ return;
445
+ }
446
+
447
+ sendJson(res, 404, { ok: false, error: '不存在: ' + method + ' ' + url.pathname });
448
+ } catch (err) {
449
+ const code = err && err.code === 'INVALID_INPUT' ? 400 : err && err.code === 'NOT_FOUND' ? 404 : 500;
450
+ sendJson(res, code, { ok: false, error: String((err && err.message) || err), code: err.code || 'INTERNAL' });
451
+ }
452
+ }
453
+
454
+ function startPopupServer(key, summary, timeoutMs) {
455
+ const server = http.createServer((req, res) => handlePopup(req, res, key, summary, timeoutMs));
456
+ return new Promise((resolve_, reject) => {
457
+ server.once('error', reject);
458
+ server.listen(0, '127.0.0.1', () => resolve_(server));
459
+ });
460
+ }
461
+
462
+ function stopServer(server) {
463
+ return new Promise((resolve_) => {
464
+ const force = setTimeout(() => {
465
+ try {
466
+ server.closeAllConnections();
467
+ } catch {
468
+ /* Node 版本不支持则忽略,close 仍会兜底 */
469
+ }
470
+ }, 800);
471
+ server.close(() => {
472
+ clearTimeout(force);
473
+ resolve_();
474
+ });
475
+ });
476
+ }