nx-kv 0.8.1 → 0.8.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.8.3] - 2026-10-12
6
+
7
+ ### Changed
8
+
9
+ - **wait 统一到常驻主面板(固定端口 7877),彻底移除随机端口临时弹窗**:
10
+ - wait 阻塞时不再在 wait 进程内临时起 http 服务器/弹窗,而是复用 `nx-kv serve` 的
11
+ 「本地任务」页:已在运行则直接打开并深链到当前主题
12
+ (`#/localtask?key=<主题>&cwd=<目录>`,前端按 key/cwd 自动选中);
13
+ - 主面板未运行时,wait 在后台 **detached 自动拉起** `nx-kv serve --no-open
14
+ --auto-exit`(独立常驻进程,健康检查通过后再开浏览器;拉起失败只降级为纯阻塞、
15
+ 绝不报错中断);
16
+ - **主面板页面常驻、提交后不关闭、可连续排队**;wait 拿到任务或超时退出后 serve 依然存活,
17
+ 回填统一由约每 0.5 秒的跨进程文件轮询取走(独立进程写入返回 `delivered:false`);
18
+ - `--no-open` 语义保持:不开浏览器、也不自动拉起 serve,仅阻塞等待(冒烟/CI 依赖)。
19
+
20
+ ### Removed
21
+
22
+ - 删除随 wait 生命周期起停的随机端口临时弹窗服务器及其内嵌页面
23
+ (`src/modules/localtask/popup.js`),以及与之重复的弹窗专用 HTTP 路由;
24
+ 实时任务统一在固定端口常驻主面板完成。
25
+ **行为变更**:不再有「wait 结束即关闭」的临时窗口,也不再输出随机端口地址——
26
+ 请改用常驻面板 `http://127.0.0.1:7877/`(wait 会自动打开/拉起)。
27
+
28
+ ## [0.8.2] - 2026-10-12
29
+
30
+ ### Added
31
+
32
+ - **实时任务 wait 返回任务时自动追加「收尾提醒」(可配置,默认开启)**,防止 agent 在长对话里
33
+ 漂移、偶尔忘记最后再 wait 一次:
34
+ - wait 交付任务时在 `task.text` 末尾拼接一段固定提醒(默认大意:规划计划、完成本任务,
35
+ 计划的最后一个动作是再次调用 `nx-kv localtask wait` 收尾本轮,拿到下一条或超时前不要提前结束)。
36
+ - 只作用于**返回给 agent 的载荷**,面板/库存里的任务原文保持干净、不被改写;原文已包含同样
37
+ 追加语时幂等不重复追加。
38
+ - 可在「本地任务」页配置(编辑、保存、恢复默认、一键关闭),CLI/HTTP:
39
+ `localtask config --suffix "<文案>"`、`--no-suffix` 关闭、`--reset-suffix` 恢复默认
40
+ (HTTP `PATCH` 传 `"suffix": ""` 关闭);
41
+ `PATCH /api/localtasks/setting` 增 `suffix` 字段,设置视图增 `taskSuffix`/`defaultTaskSuffix`/`suffixEnabled`。
42
+
43
+ ### Fixed
44
+
45
+ - `loadStore` 重建 settings 时只保留 `timeoutSec`、把 `taskSuffix` 字段丢掉,导致
46
+ 第一次 PATCH 关闭后第二次 PATCH 改超时,taskSuffix 会被默认值覆盖(仅 0.8.1 受影响)。
47
+
5
48
  ## [0.8.1] - 2026-10-12
6
49
 
7
50
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -181,10 +181,24 @@ export default {
181
181
  id: 'localtask.config',
182
182
  cli: ['localtask', 'config'],
183
183
  http: ['PATCH', '/api/localtasks/setting'],
184
- summary: '配置等待超时(秒;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)',
185
- flags: { timeout: { type: 'number', required: true, hint: '超时秒数(1-600)' } },
186
- run: (ctx) => service.updateSettings({ timeoutSec: ctx.timeout }),
187
- render: (s) => `已配置等待超时 ${s.timeoutSec} 秒(推荐 ${s.recommendedSec},最大 ${s.maxSec})`,
184
+ summary: '配置等待超时(秒)/ 收尾追加语(task.text 末尾自动追加的固定指令;置空关闭)',
185
+ flags: {
186
+ timeout: { type: 'number', hint: '超时秒数(1-600)' },
187
+ suffix: { type: 'string', hint: '收尾追加语文本(与 --reset-suffix/--no-suffix 互斥)' },
188
+ 'reset-suffix': { type: 'boolean', hint: '恢复默认收尾追加语' },
189
+ 'no-suffix': { type: 'boolean', hint: '清空并关闭收尾追加语' },
190
+ },
191
+ run: (ctx) => {
192
+ const patch = { timeoutSec: ctx.timeout };
193
+ if (ctx['no-suffix']) patch.taskSuffix = '';
194
+ else if (ctx['reset-suffix']) patch.taskSuffix = service.DEFAULT_TASK_SUFFIX;
195
+ else if (ctx.suffix !== undefined) patch.taskSuffix = ctx.suffix;
196
+ return service.updateSettings(patch);
197
+ },
198
+ render: (s) => {
199
+ const x = s.suffixEnabled ? '收尾追加语已开启' : '收尾追加语已关闭';
200
+ return `已配置等待超时 ${s.timeoutSec} 秒(推荐 ${s.recommendedSec},最大 ${s.maxSec})· ${x}`;
201
+ },
188
202
  },
189
203
 
190
204
  {
@@ -257,5 +271,16 @@ export default {
257
271
  run: (ctx) => service.cancelWait({ cwd: ctx.cwd, key: ctx.key }),
258
272
  render: renderCancel,
259
273
  },
274
+
275
+ {
276
+ // 探测常驻面板上当前正打开的「实时任务」标签数(活动 SSE 连接数)。
277
+ // wait 开页前先经此端点:有活标签就复用、不再 openBrowser 新开页(避免 Edge 重复开标签);
278
+ // 没有活标签时才开页深链。探测失败 / 旧版无此端点时 service 保守返回 0,不阻塞 wait。
279
+ id: 'localtask.presence',
280
+ cli: ['localtask', 'presence'],
281
+ http: ['GET', '/api/localtasks/presence'],
282
+ summary: '探测主面板上当前打开的「实时任务」标签数(活动 SSE 连接数;用于 wait 不重复开页)',
283
+ run: () => service.panelPresence(),
284
+ },
260
285
  ],
261
286
  };
@@ -24,11 +24,12 @@
24
24
  import http from 'node:http';
25
25
  import crypto from 'node:crypto';
26
26
  import fsp from 'node:fs/promises';
27
+ import { spawn } from 'node:child_process';
27
28
  import { basename, dirname, join, resolve } from 'node:path';
28
- import { APP_DIR } from '../../core/paths.js';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { APP_DIR, DEFAULT_PORT } from '../../core/paths.js';
29
31
  import { badInput } from '../../core/errors.js';
30
32
  import { openBrowser } from '../../core/open.js';
31
- import { renderPopupPage } from './popup.js';
32
33
 
33
34
  export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
34
35
  const POLL_MS = 500; // 跨进程兜底轮询间隔
@@ -37,6 +38,25 @@ export const MAX_TIMEOUT_SEC = 600; // 阻塞时长上限(10 分钟)
37
38
  export const MAX_SUMMARY = 2000; // 现状(状态)长度上限(原 200,已放宽)
38
39
  export const MAX_TEXT = 20_000; // 单条任务内容长度上限
39
40
 
41
+ // wait 取任务返回时,在 task.text 末尾追加的「收尾指令」(可在面板/CLI 修改;置空关闭)。
42
+ // 作用:从每一条任务层面反复强调「计划最后再 wait 一次」,避免 agent 在长对话里漂移、漏调。
43
+ // 注意:命令名是 localtask 不是 sessiontask(v0.8.0 重命名后),但默认文案直接复用同一段文字。
44
+ export const DEFAULT_TASK_SUFFIX =
45
+ '规划计划,然后完成这个任务;计划的最后一个动作,是再次调用 ' +
46
+ 'nx-kv localtask wait(带上 --cwd 当前工作目录、--summary 当前现状、--json)收尾本轮,' +
47
+ '在拿到下一条任务或超时之前不要提前结束。';
48
+ const MAX_SUFFIX_LEN = 500; // 收尾追加语长度上限
49
+
50
+ // 归一化收尾追加语:缺省(旧 store 无此字段)→ 默认;显式传 ''(清空)→ '' 表示关闭。
51
+ // 注意:与 timeoutSec 不同——timeoutSec 缺省→推荐值(仍开启),taskSuffix 缺省→默认文本。
52
+ // 因此从老 store 读 settings.taskSuffix 时,clampTaskSuffix 会自动补默认;想"关闭"必须显式 ''。
53
+ export function clampTaskSuffix(v) {
54
+ if (v === undefined || v === null) return DEFAULT_TASK_SUFFIX;
55
+ let s = String(v).trim();
56
+ if (s.length > MAX_SUFFIX_LEN) s = s.slice(0, MAX_SUFFIX_LEN);
57
+ return s;
58
+ }
59
+
40
60
  // ─── 存储位置:可环境变量覆盖(测试隔离);旧名/旧文件自动兼容 ──────────
41
61
 
42
62
  export function storePath() {
@@ -119,7 +139,11 @@ export function stampNow(d = new Date()) {
119
139
  // ─── 存储读写(原子写 + 损坏容忍 + v1/v2 迁移) ───────────────────
120
140
 
121
141
  function emptyStore() {
122
- return { version: 4, settings: { timeoutSec: DEFAULT_TIMEOUT_SEC }, topics: {} };
142
+ return {
143
+ version: 4,
144
+ settings: { timeoutSec: DEFAULT_TIMEOUT_SEC, taskSuffix: DEFAULT_TASK_SUFFIX },
145
+ topics: {},
146
+ };
123
147
  }
124
148
 
125
149
  // 归一化超时秒数:非法/缺省 → 推荐值;超上限截断为 10 分钟
@@ -182,7 +206,14 @@ async function loadStore() {
182
206
  if (!topics) return emptyStore();
183
207
  return {
184
208
  version: 4,
185
- settings: { timeoutSec: clampTimeoutSec(settings && settings.timeoutSec) },
209
+ settings: {
210
+ timeoutSec: clampTimeoutSec(settings && settings.timeoutSec),
211
+ // 旧 store 可能没存过 taskSuffix(v0.8.0 之前);clampTaskSuffix 缺省→默认文本。
212
+ // 显式 ''(关闭)会保留——判断条件是「字段存在且非 undefined」,不能只看 falsy。
213
+ taskSuffix: settings && Object.prototype.hasOwnProperty.call(settings, 'taskSuffix')
214
+ ? clampTaskSuffix(settings.taskSuffix)
215
+ : DEFAULT_TASK_SUFFIX,
216
+ },
186
217
  topics,
187
218
  };
188
219
  }
@@ -358,6 +389,10 @@ function settingsView(s) {
358
389
  timeoutSec: clampTimeoutSec(s && s.timeoutSec),
359
390
  recommendedSec: DEFAULT_TIMEOUT_SEC,
360
391
  maxSec: MAX_TIMEOUT_SEC,
392
+ // wait 交付任务时追加到 task.text 末尾的收尾提醒;''=关闭;defaultTaskSuffix 供 UI 恢复默认
393
+ taskSuffix: clampTaskSuffix(s && s.taskSuffix),
394
+ defaultTaskSuffix: DEFAULT_TASK_SUFFIX,
395
+ suffixEnabled: !!clampTaskSuffix(s && s.taskSuffix),
361
396
  };
362
397
  }
363
398
 
@@ -402,14 +437,27 @@ export async function pruneTopics({ allFinished = false, dryRun = false } = {})
402
437
  return { status: 'ok', dryRun: !!dryRun, count: removed.length, removed };
403
438
  }
404
439
 
405
- // 配置等待超时(页面可调;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)
406
- export async function updateSettings({ timeoutSec } = {}) {
407
- const n = Number(timeoutSec);
408
- if (!Number.isFinite(n) || n <= 0) {
409
- throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
440
+ // 配置等待超时 / 任务收尾追加语(页面可调)。超时推荐 180=3 分钟、最大 600=10 分钟;
441
+ // taskSuffix 为 wait 返回任务时追加到 task.text 末尾的固定指令,传 '' 关闭、不传保持不变。
442
+ export async function updateSettings({ timeoutSec, taskSuffix } = {}) {
443
+ const hasTimeout = timeoutSec !== undefined && timeoutSec !== null;
444
+ const hasSuffix = taskSuffix !== undefined;
445
+ if (!hasTimeout && !hasSuffix) {
446
+ throw badInput(
447
+ '请至少指定一项:--timeout <秒>,或 --suffix "<收尾追加语>"(--no-suffix 关闭追加、--reset-suffix 恢复默认)'
448
+ );
410
449
  }
411
450
  const store = await loadStore();
412
- store.settings = { timeoutSec: clampTimeoutSec(n) };
451
+ const next = { ...store.settings };
452
+ if (hasTimeout) {
453
+ const n = Number(timeoutSec);
454
+ if (!Number.isFinite(n) || n <= 0) {
455
+ throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
456
+ }
457
+ next.timeoutSec = clampTimeoutSec(n);
458
+ }
459
+ if (hasSuffix) next.taskSuffix = clampTaskSuffix(taskSuffix);
460
+ store.settings = next;
413
461
  await saveStore(store);
414
462
  return settingsView(store.settings);
415
463
  }
@@ -628,6 +676,16 @@ export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {})
628
676
 
629
677
  // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
630
678
 
679
+ // 把收尾追加语拼到返回给 agent 的 task.text 末尾(只改返回载荷,绝不回写库存原文)。
680
+ // suffix 为空 → 原样返回;已包含同样追加语(幂等)→ 不重复追加。
681
+ function withTaskSuffix(task, suffix) {
682
+ const s = clampTaskSuffix(suffix);
683
+ if (!task || !s) return task;
684
+ const base = String(task.text || '');
685
+ if (base.includes(s)) return task;
686
+ return { ...task, text: `${base.replace(/\s+$/, '')}\n\n${s}` };
687
+ }
688
+
631
689
  /**
632
690
  * 针对某主题(工作目录)等待用户回填下一条任务。
633
691
  * @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd
@@ -649,19 +707,30 @@ export async function waitForTask({
649
707
  const summary = normalizeSummary(rawSummary); // 现状必填
650
708
 
651
709
  // 超时:显式 --timeout(秒/毫秒)优先;否则用页面配置;统一夹到 1-600
710
+ const startStore = await loadStore();
652
711
  let sec;
653
712
  if (timeoutSec !== undefined && timeoutSec !== null) sec = timeoutSec;
654
713
  else if (timeoutMs !== undefined && timeoutMs !== null) sec = Number(timeoutMs) / 1000;
655
- else sec = (await loadStore()).settings.timeoutSec;
714
+ else sec = startStore.settings.timeoutSec;
656
715
  const tmo = clampTimeoutSec(sec) * 1000;
716
+ const suffix = startStore.settings.taskSuffix; // 返回任务时追加的收尾指令(本轮固定)
657
717
 
658
718
  // 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞;记录本轮完成总结
659
719
  const queued = await consumeFirstPending(key, summary);
660
- if (queued) return { status: 'ok', key, cwd, name, summary, task: queued, queued: true };
720
+ if (queued) {
721
+ return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true };
722
+ }
661
723
 
662
- // 2. 没有 pending → 起内嵌弹窗服务器
663
- const server = await startPopupServer(key, cwd, summary, tmo);
664
- const addr = `http://127.0.0.1:${server.address().port}/`;
724
+ // 2. 没有 pending → 复用常驻主面板(不再起随机端口临时弹窗):
725
+ // a) 确保主面板在跑(不存在就 detached 拉起 serve --no-open --auto-exit);
726
+ // b) 探测活动 SSE 连接:有活标签就不开新页(避免 Edge 重复开标签);
727
+ // 没活标签就 openBrowser 深链到当前主题。
728
+ // 失败只降级为纯阻塞——绝不抛错中断 wait。
729
+ // --no-open 模式(冒烟/CI)不发起任何面板相关动作:不开浏览器、不拉起 serve、不开 SSE。
730
+ // 这样可避免在测试里 spawn 出一堆孤儿进程占着 7877 端口。
731
+ const panelInfo = open ? await ensureMainPanel() : { running: false, started: false, url: '' };
732
+ const presence = panelInfo.running ? await probePresence() : 0;
733
+ const deepLink = deepLinkUrl(key, cwd);
665
734
 
666
735
  // 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
667
736
  let resolveOutcome;
@@ -694,7 +763,7 @@ export async function waitForTask({
694
763
  .catch(() => {});
695
764
  }, POLL_MS);
696
765
 
697
- // 登记「等待中」(含截止时刻与现状),面板/弹窗据此显示倒计时
766
+ // 登记「等待中」(含截止时刻与现状),面板据此显示倒计时
698
767
  await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
699
768
 
700
769
  // 登记之后再查一次关闭标记:setWaiting 刚清掉上一轮残留的标记,因此这里若有值
@@ -706,13 +775,32 @@ export async function waitForTask({
706
775
  // waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
707
776
  if (typeof onReady === 'function') {
708
777
  try {
709
- await onReady({ addr, key, cwd, name, summary });
778
+ await onReady({ addr: deepLink, key, cwd, name, summary });
710
779
  } catch {
711
780
  /* 钩子异常不得影响 wait */
712
781
  }
713
782
  }
714
783
 
715
- if (open) openBrowser(addr);
784
+ // 没活标签且主面板在跑 → 开浏览器深链(让用户能在面板里选中主题并响应等待事件)。
785
+ // 已有活标签 / --no-open / 主面板未启动 → 都不开(避免重复标签 + 冒烟/CI 兼容)。
786
+ const openPage = open && presence === 0 && panelInfo.running;
787
+ if (openPage) openBrowser(deepLink);
788
+
789
+ // 周期复查:等待期间每 5s 查一次活动标签;万一开始时判错(页面被关),20s 冷却内重开。
790
+ // 不到点不开新页(节流,避免「标签被关 → 立刻开 → 立刻关」抖动)。
791
+ const REVIEW_MS = 5_000;
792
+ const REOPEN_COOLDOWN_MS = 20_000;
793
+ let lastReopenAt = 0;
794
+ const review = setInterval(async () => {
795
+ if (!panelInfo.running || !open) return;
796
+ const n = await probePresence().catch(() => 0);
797
+ if (n > 0) return; // 有活标签,不动
798
+ const now = Date.now();
799
+ if (now - lastReopenAt < REOPEN_COOLDOWN_MS) return; // 冷却中
800
+ lastReopenAt = now;
801
+ openBrowser(deepLink);
802
+ }, REVIEW_MS);
803
+ review.unref?.();
716
804
 
717
805
  let result;
718
806
  try {
@@ -722,127 +810,18 @@ export async function waitForTask({
722
810
  ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
723
811
  : r.kind === 'cancelled'
724
812
  ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo, cancelled: true }
725
- : { status: 'ok', key, cwd, name, summary, task: r.task, delivered: r.delivered, queued: false };
813
+ : { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(r.task, suffix), delivered: r.delivered, queued: false };
726
814
  } finally {
727
815
  clearInterval(poll);
728
816
  clearInterval(cancelPoll);
817
+ clearInterval(review);
729
818
  clearTimeout(timer);
730
819
  waiters.delete(key);
731
820
  await clearWaiting(key);
732
- await stopServer(server);
733
821
  }
734
822
  return result;
735
823
  }
736
824
 
737
- // ─── 内嵌弹窗服务器 ─────────────────────────────────────────────────
738
-
739
- function sendJson(res, status, obj) {
740
- res.writeHead(status, {
741
- 'content-type': 'application/json; charset=utf-8',
742
- 'cache-control': 'no-store',
743
- });
744
- res.end(JSON.stringify(obj));
745
- }
746
-
747
- function sendHtml(res, html) {
748
- res.writeHead(200, {
749
- 'content-type': 'text/html; charset=utf-8',
750
- 'cache-control': 'no-store',
751
- });
752
- res.end(html);
753
- }
754
-
755
- async function readBody(req) {
756
- const chunks = [];
757
- let size = 0;
758
- for await (const c of req) {
759
- size += c.length;
760
- if (size > 1024 * 1024) throw new Error('请求体过大');
761
- chunks.push(c);
762
- }
763
- if (!chunks.length) return {};
764
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
765
- }
766
-
767
- // 与主 api.js 同一套跨站防护:弹窗服务器虽只绑 127.0.0.1,浏览器里任意页面都能
768
- // 向它发请求,非本机来源一律挡。
769
- function originAllowed(req) {
770
- const origin = req.headers.origin;
771
- if (!origin) return true;
772
- try {
773
- const h = new URL(origin).hostname;
774
- return h === '127.0.0.1' || h === 'localhost' || h === '::1';
775
- } catch {
776
- return false;
777
- }
778
- }
779
-
780
- async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
781
- const url = new URL(req.url, 'http://127.0.0.1/');
782
- const method = (req.method || 'GET').toUpperCase();
783
-
784
- try {
785
- if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
786
- sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
787
- return;
788
- }
789
-
790
- if (url.pathname === '/api/localtasks' && method === 'GET') {
791
- sendJson(res, 200, { ok: true, data: await listTasks() });
792
- return;
793
- }
794
-
795
- if (url.pathname === '/api/localtasks' && method === 'POST') {
796
- if (!originAllowed(req)) {
797
- sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
798
- return;
799
- }
800
- const body = await readBody(req);
801
- sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
802
- return;
803
- }
804
-
805
- if (url.pathname === '/api/localtasks/item' && method === 'DELETE') {
806
- if (!originAllowed(req)) {
807
- sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
808
- } else {
809
- const body = await readBody(req);
810
- sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
811
- }
812
- return;
813
- }
814
-
815
- sendJson(res, 404, { ok: false, error: '不存在: ' + method + ' ' + url.pathname });
816
- } catch (err) {
817
- const code = err && err.code === 'INVALID_INPUT' ? 400 : err && err.code === 'NOT_FOUND' ? 404 : 500;
818
- sendJson(res, code, { ok: false, error: String((err && err.message) || err), code: err.code || 'INTERNAL' });
819
- }
820
- }
821
-
822
- function startPopupServer(key, cwd, summary, timeoutMs) {
823
- const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
824
- return new Promise((resolve_, reject) => {
825
- server.once('error', reject);
826
- server.listen(0, '127.0.0.1', () => resolve_(server));
827
- });
828
- }
829
-
830
- function stopServer(server) {
831
- return new Promise((resolve_) => {
832
- const force = setTimeout(() => {
833
- try {
834
- server.closeAllConnections();
835
- } catch {
836
- /* Node 版本不支持则忽略,close 仍会兜底 */
837
- }
838
- }, 800);
839
- server.close(() => {
840
- clearTimeout(force);
841
- resolve_();
842
- });
843
- });
844
- }
845
-
846
825
  // ─── 面板空闲自释放所需的观测接口(panel-guard.js 调用)────────────
847
826
 
848
827
  // 当前有多少主题「真的在等待」——常驻面板的空闲自释放据此否决退出:
@@ -913,3 +892,102 @@ export function addEventStreamConn(req, res) {
913
892
  res.on('close', cleanup);
914
893
  return conn;
915
894
  }
895
+
896
+ // ─── 常驻主面板(固定端口)统一入口 ───────────────────────────────────
897
+
898
+ // 「主面板就绪」=端口上有可响应的服务。
899
+ // 这与 /api/ping 不同:ping 是面板壳层每 15s 主动上报「有页面开着」,
900
+ // 这里只问「端口上有没有人接 HTTP」——用于决定是否要拉起 serve。
901
+ async function probePanelReady(timeoutMs = 900) {
902
+ const url = `${panelBaseUrl()}/api/ping`;
903
+ return new Promise((resolve_) => {
904
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
905
+ res.resume();
906
+ resolve_(true);
907
+ });
908
+ req.on('timeout', () => {
909
+ req.destroy();
910
+ resolve_(false);
911
+ });
912
+ req.on('error', () => resolve_(false));
913
+ });
914
+ }
915
+
916
+ // 「活动 SSE 连接数」= 主面板上当前正打开的「实时任务」标签数。
917
+ // 用于 wait 决策:已有活标签就不重开页面,让那个标签响铃/打角标/弹通知;
918
+ // 没有活标签时再 openBrowser 新开深链标签。
919
+ //
920
+ // 任何失败/旧版无此端点都按 0 处理(保守地开一次页),绝不因探测失败而阻塞 wait。
921
+ async function probePresence(timeoutMs = 900) {
922
+ const url = `${panelBaseUrl()}/api/localtasks/presence`;
923
+ return new Promise((resolve_) => {
924
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
925
+ let body = '';
926
+ res.setEncoding('utf8');
927
+ res.on('data', (c) => {
928
+ body += c;
929
+ });
930
+ res.on('end', () => {
931
+ try {
932
+ const j = JSON.parse(body);
933
+ const d = j && typeof j === 'object' && j.data ? j.data : j;
934
+ resolve_(Math.max(0, Number(d && d.clients) || 0));
935
+ } catch {
936
+ resolve_(0);
937
+ }
938
+ });
939
+ });
940
+ req.on('timeout', () => {
941
+ req.destroy();
942
+ resolve_(0);
943
+ });
944
+ req.on('error', () => resolve_(0));
945
+ });
946
+ }
947
+
948
+ function panelBaseUrl() {
949
+ return `http://127.0.0.1:${DEFAULT_PORT}`;
950
+ }
951
+
952
+ // 深链到主面板「实时任务」页并选中当前主题(key 优先,cwd 兜底)。
953
+ function deepLinkUrl(key, cwd) {
954
+ return `${panelBaseUrl()}/#/localtask?key=${encodeURIComponent(key)}&cwd=${encodeURIComponent(cwd)}`;
955
+ }
956
+
957
+ // 本仓库/已安装包的 CLI 入口(src/modules/localtask/ 上三级到包根)。
958
+ function binCliPath() {
959
+ return fileURLToPath(new URL('../../../bin/cli.mjs', import.meta.url));
960
+ }
961
+
962
+ // 确保常驻主面板可用:已在运行直接复用;否则后台 detached 拉起一个独立
963
+ // `nx-kv serve --no-open --auto-exit`(unref,不随 wait 退出而关闭),再轮询健康端点。
964
+ // 失败只返回 running:false,绝不抛错——wait 仍可阻塞等待 CLI/轮询回填。
965
+ //
966
+ // --auto-exit:这个实例是**为等用户回填而临时拉起**的,用完就该让位——面板页全部关闭、
967
+ // 又没有等待中的主题时自行退出(宽限期见 panel-guard.js)。下一次 wait 需要时会再拉起
968
+ // (冷启动 <1s)。用户手动 `nx-kv serve` 的实例不带此标志,不会自己跑掉。
969
+ async function ensureMainPanel() {
970
+ const url = panelBaseUrl() + '/';
971
+ if (await probePanelReady()) return { running: true, started: false, url };
972
+
973
+ try {
974
+ const child = spawn(process.execPath, [binCliPath(), 'serve', '--no-open', '--auto-exit'], {
975
+ detached: true,
976
+ stdio: 'ignore',
977
+ windowsHide: true,
978
+ });
979
+ child.on('error', () => {
980
+ /* 见轮询兜底 */
981
+ });
982
+ child.unref();
983
+ } catch {
984
+ return { running: false, started: false, url };
985
+ }
986
+
987
+ const deadline = Date.now() + 15_000;
988
+ while (Date.now() < deadline) {
989
+ await new Promise((r) => setTimeout(r, 200));
990
+ if (await probePanelReady(700)) return { running: true, started: true, url };
991
+ }
992
+ return { running: false, started: true, url };
993
+ }