nx-kv 0.8.0 → 0.8.2

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.
@@ -37,6 +37,25 @@ export const MAX_TIMEOUT_SEC = 600; // 阻塞时长上限(10 分钟)
37
37
  export const MAX_SUMMARY = 2000; // 现状(状态)长度上限(原 200,已放宽)
38
38
  export const MAX_TEXT = 20_000; // 单条任务内容长度上限
39
39
 
40
+ // wait 取任务返回时,在 task.text 末尾追加的「收尾指令」(可在面板/CLI 修改;置空关闭)。
41
+ // 作用:从每一条任务层面反复强调「计划最后再 wait 一次」,避免 agent 在长对话里漂移、漏调。
42
+ // 注意:命令名是 localtask 不是 sessiontask(v0.8.0 重命名后),但默认文案直接复用同一段文字。
43
+ export const DEFAULT_TASK_SUFFIX =
44
+ '规划计划,然后完成这个任务;计划的最后一个动作,是再次调用 ' +
45
+ 'nx-kv localtask wait(带上 --cwd 当前工作目录、--summary 当前现状、--json)收尾本轮,' +
46
+ '在拿到下一条任务或超时之前不要提前结束。';
47
+ const MAX_SUFFIX_LEN = 500; // 收尾追加语长度上限
48
+
49
+ // 归一化收尾追加语:缺省(旧 store 无此字段)→ 默认;显式传 ''(清空)→ '' 表示关闭。
50
+ // 注意:与 timeoutSec 不同——timeoutSec 缺省→推荐值(仍开启),taskSuffix 缺省→默认文本。
51
+ // 因此从老 store 读 settings.taskSuffix 时,clampTaskSuffix 会自动补默认;想"关闭"必须显式 ''。
52
+ export function clampTaskSuffix(v) {
53
+ if (v === undefined || v === null) return DEFAULT_TASK_SUFFIX;
54
+ let s = String(v).trim();
55
+ if (s.length > MAX_SUFFIX_LEN) s = s.slice(0, MAX_SUFFIX_LEN);
56
+ return s;
57
+ }
58
+
40
59
  // ─── 存储位置:可环境变量覆盖(测试隔离);旧名/旧文件自动兼容 ──────────
41
60
 
42
61
  export function storePath() {
@@ -119,7 +138,11 @@ export function stampNow(d = new Date()) {
119
138
  // ─── 存储读写(原子写 + 损坏容忍 + v1/v2 迁移) ───────────────────
120
139
 
121
140
  function emptyStore() {
122
- return { version: 4, settings: { timeoutSec: DEFAULT_TIMEOUT_SEC }, topics: {} };
141
+ return {
142
+ version: 4,
143
+ settings: { timeoutSec: DEFAULT_TIMEOUT_SEC, taskSuffix: DEFAULT_TASK_SUFFIX },
144
+ topics: {},
145
+ };
123
146
  }
124
147
 
125
148
  // 归一化超时秒数:非法/缺省 → 推荐值;超上限截断为 10 分钟
@@ -182,7 +205,14 @@ async function loadStore() {
182
205
  if (!topics) return emptyStore();
183
206
  return {
184
207
  version: 4,
185
- settings: { timeoutSec: clampTimeoutSec(settings && settings.timeoutSec) },
208
+ settings: {
209
+ timeoutSec: clampTimeoutSec(settings && settings.timeoutSec),
210
+ // 旧 store 可能没存过 taskSuffix(v0.8.0 之前);clampTaskSuffix 缺省→默认文本。
211
+ // 显式 ''(关闭)会保留——判断条件是「字段存在且非 undefined」,不能只看 falsy。
212
+ taskSuffix: settings && Object.prototype.hasOwnProperty.call(settings, 'taskSuffix')
213
+ ? clampTaskSuffix(settings.taskSuffix)
214
+ : DEFAULT_TASK_SUFFIX,
215
+ },
186
216
  topics,
187
217
  };
188
218
  }
@@ -248,6 +278,8 @@ function topicView(t) {
248
278
  summary: t.summary || '',
249
279
  waiting: !!t.waiting,
250
280
  waitingUntil: t.waitingUntil || '',
281
+ waitingSince: t.waitingSince || '',
282
+ cancelRequested: !!t.cancelRequested,
251
283
  createdAt: t.createdAt,
252
284
  updatedAt: t.updatedAt,
253
285
  pending: tasks.filter((x) => x.status !== 'consumed').length,
@@ -282,12 +314,17 @@ export async function consumeFirstPending(key, roundSummary) {
282
314
  return normalizeTask(task);
283
315
  }
284
316
 
285
- // 在 store 里把某主题置为等待/结束,并刷新现状
317
+ // 在 store 里把某主题置为等待/结束,并刷新现状。
318
+ // waitingSince 是「等待起点」,面板据此显示「已等多久」——waitingUntil(截止时刻)
319
+ // 只能反推剩余,反推不出起点(超时值可被改写),所以两者都存。
286
320
  async function setWaiting(key, cwd, summary, waiting, deadline) {
287
321
  const store = await loadStore();
288
322
  const t = topicOf(store, key, cwd);
289
- t.waiting = waiting;
323
+ t.waiting = !!waiting;
290
324
  t.waitingUntil = waiting && deadline ? stampNow(deadline) : '';
325
+ t.waitingSince = waiting ? stampNow() : '';
326
+ // 新一轮等待不继承上一轮残留的「关闭」请求,否则刚进来就被立刻收尾
327
+ t.cancelRequested = false;
291
328
  if (summary) t.summary = summary;
292
329
  t.updatedAt = stampNow();
293
330
  await saveStore(store);
@@ -301,8 +338,47 @@ async function clearWaiting(key) {
301
338
  if (!t) return;
302
339
  t.waiting = false;
303
340
  t.waitingUntil = '';
341
+ t.waitingSince = '';
342
+ t.cancelRequested = false;
343
+ t.updatedAt = stampNow();
344
+ await saveStore(store);
345
+ }
346
+
347
+ /**
348
+ * 用户在面板点「关闭」:关掉某主题的等待,让**阻塞中的 wait 立即收尾**。
349
+ *
350
+ * 只翻 store 里的 cancelRequested 标记——wait 端每 0.5s 轮询到就结束本轮(返回
351
+ * status:"timeout" + cancelled:true,与超时同一条出口,agent 照常收尾)。
352
+ * **不删主题、不删任务、不动队列**:等待可以取消,历史照留。
353
+ *
354
+ * @returns {{cancelled:boolean,key:string,name:string,cwd:string,reason?:string}}
355
+ */
356
+ export async function cancelWait({ cwd: rawCwd, key: rawKey } = {}) {
357
+ if (!rawKey && !rawCwd) {
358
+ const { badInput } = await import('../../core/errors.js');
359
+ throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
360
+ }
361
+ const key = resolveTopicKey({ key: rawKey, cwd: rawCwd });
362
+ const store = await loadStore();
363
+ const t = store.topics[key];
364
+ if (!t || !t.waiting) {
365
+ return { cancelled: false, key, name: (t && t.name) || '', cwd: (t && t.cwd) || '', reason: 'not_waiting' };
366
+ }
367
+ t.cancelRequested = true;
368
+ t.updatedAt = stampNow();
369
+ await saveStore(store);
370
+ return { cancelled: true, key, name: t.name, cwd: t.cwd };
371
+ }
372
+
373
+ // wait 端轮询:该主题是否被请求关闭;取到即清标记(只生效一次)。
374
+ async function consumeCancelRequest(key) {
375
+ const store = await loadStore();
376
+ const t = store.topics[key];
377
+ if (!t || !t.cancelRequested) return false;
378
+ t.cancelRequested = false;
304
379
  t.updatedAt = stampNow();
305
380
  await saveStore(store);
381
+ return true;
306
382
  }
307
383
 
308
384
  // ─── CRUD ───────────────────────────────────────────────────────────
@@ -312,6 +388,10 @@ function settingsView(s) {
312
388
  timeoutSec: clampTimeoutSec(s && s.timeoutSec),
313
389
  recommendedSec: DEFAULT_TIMEOUT_SEC,
314
390
  maxSec: MAX_TIMEOUT_SEC,
391
+ // wait 交付任务时追加到 task.text 末尾的收尾提醒;''=关闭;defaultTaskSuffix 供 UI 恢复默认
392
+ taskSuffix: clampTaskSuffix(s && s.taskSuffix),
393
+ defaultTaskSuffix: DEFAULT_TASK_SUFFIX,
394
+ suffixEnabled: !!clampTaskSuffix(s && s.taskSuffix),
315
395
  };
316
396
  }
317
397
 
@@ -356,14 +436,27 @@ export async function pruneTopics({ allFinished = false, dryRun = false } = {})
356
436
  return { status: 'ok', dryRun: !!dryRun, count: removed.length, removed };
357
437
  }
358
438
 
359
- // 配置等待超时(页面可调;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)
360
- export async function updateSettings({ timeoutSec } = {}) {
361
- const n = Number(timeoutSec);
362
- if (!Number.isFinite(n) || n <= 0) {
363
- throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
439
+ // 配置等待超时 / 任务收尾追加语(页面可调)。超时推荐 180=3 分钟、最大 600=10 分钟;
440
+ // taskSuffix 为 wait 返回任务时追加到 task.text 末尾的固定指令,传 '' 关闭、不传保持不变。
441
+ export async function updateSettings({ timeoutSec, taskSuffix } = {}) {
442
+ const hasTimeout = timeoutSec !== undefined && timeoutSec !== null;
443
+ const hasSuffix = taskSuffix !== undefined;
444
+ if (!hasTimeout && !hasSuffix) {
445
+ throw badInput(
446
+ '请至少指定一项:--timeout <秒>,或 --suffix "<收尾追加语>"(--no-suffix 关闭追加、--reset-suffix 恢复默认)'
447
+ );
364
448
  }
365
449
  const store = await loadStore();
366
- store.settings = { timeoutSec: clampTimeoutSec(n) };
450
+ const next = { ...store.settings };
451
+ if (hasTimeout) {
452
+ const n = Number(timeoutSec);
453
+ if (!Number.isFinite(n) || n <= 0) {
454
+ throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
455
+ }
456
+ next.timeoutSec = clampTimeoutSec(n);
457
+ }
458
+ if (hasSuffix) next.taskSuffix = clampTaskSuffix(taskSuffix);
459
+ store.settings = next;
367
460
  await saveStore(store);
368
461
  return settingsView(store.settings);
369
462
  }
@@ -582,6 +675,16 @@ export async function reorderTasks({ cwd: rawCwd, id, beforeId, afterId } = {})
582
675
 
583
676
  // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
584
677
 
678
+ // 把收尾追加语拼到返回给 agent 的 task.text 末尾(只改返回载荷,绝不回写库存原文)。
679
+ // suffix 为空 → 原样返回;已包含同样追加语(幂等)→ 不重复追加。
680
+ function withTaskSuffix(task, suffix) {
681
+ const s = clampTaskSuffix(suffix);
682
+ if (!task || !s) return task;
683
+ const base = String(task.text || '');
684
+ if (base.includes(s)) return task;
685
+ return { ...task, text: `${base.replace(/\s+$/, '')}\n\n${s}` };
686
+ }
687
+
585
688
  /**
586
689
  * 针对某主题(工作目录)等待用户回填下一条任务。
587
690
  * @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd
@@ -603,15 +706,19 @@ export async function waitForTask({
603
706
  const summary = normalizeSummary(rawSummary); // 现状必填
604
707
 
605
708
  // 超时:显式 --timeout(秒/毫秒)优先;否则用页面配置;统一夹到 1-600
709
+ const startStore = await loadStore();
606
710
  let sec;
607
711
  if (timeoutSec !== undefined && timeoutSec !== null) sec = timeoutSec;
608
712
  else if (timeoutMs !== undefined && timeoutMs !== null) sec = Number(timeoutMs) / 1000;
609
- else sec = (await loadStore()).settings.timeoutSec;
713
+ else sec = startStore.settings.timeoutSec;
610
714
  const tmo = clampTimeoutSec(sec) * 1000;
715
+ const suffix = startStore.settings.taskSuffix; // 返回任务时追加的收尾指令(本轮固定)
611
716
 
612
717
  // 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞;记录本轮完成总结
613
718
  const queued = await consumeFirstPending(key, summary);
614
- if (queued) return { status: 'ok', key, cwd, name, summary, task: queued, queued: true };
719
+ if (queued) {
720
+ return { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(queued, suffix), queued: true };
721
+ }
615
722
 
616
723
  // 2. 没有 pending → 起内嵌弹窗服务器
617
724
  const server = await startPopupServer(key, cwd, summary, tmo);
@@ -638,9 +745,25 @@ export async function waitForTask({
638
745
 
639
746
  const timer = setTimeout(() => resolveOutcome({ kind: 'timeout' }), tmo);
640
747
 
748
+ // 关闭轮询:用户在面板点了「关闭」→ 立即收尾本轮(走与超时同一条出口,只多带 cancelled 标记,
749
+ // agent 不必认识新状态,照常结束本轮)。
750
+ const cancelPoll = setInterval(() => {
751
+ consumeCancelRequest(key)
752
+ .then((yes) => {
753
+ if (yes) resolveOutcome({ kind: 'cancelled' });
754
+ })
755
+ .catch(() => {});
756
+ }, POLL_MS);
757
+
641
758
  // 登记「等待中」(含截止时刻与现状),面板/弹窗据此显示倒计时
642
759
  await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
643
760
 
761
+ // 登记之后再查一次关闭标记:setWaiting 刚清掉上一轮残留的标记,因此这里若有值
762
+ // 一定是新一次关闭请求——立刻收尾,不让用户白等。
763
+ if (await consumeCancelRequest(key).catch(() => false)) {
764
+ resolveOutcome({ kind: 'cancelled' });
765
+ }
766
+
644
767
  // waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
645
768
  if (typeof onReady === 'function') {
646
769
  try {
@@ -658,9 +781,12 @@ export async function waitForTask({
658
781
  result =
659
782
  r.kind === 'timeout'
660
783
  ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
661
- : { status: 'ok', key, cwd, name, summary, task: r.task, delivered: r.delivered, queued: false };
784
+ : r.kind === 'cancelled'
785
+ ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo, cancelled: true }
786
+ : { status: 'ok', key, cwd, name, summary, task: withTaskSuffix(r.task, suffix), delivered: r.delivered, queued: false };
662
787
  } finally {
663
788
  clearInterval(poll);
789
+ clearInterval(cancelPoll);
664
790
  clearTimeout(timer);
665
791
  waiters.delete(key);
666
792
  await clearWaiting(key);
@@ -777,3 +903,74 @@ function stopServer(server) {
777
903
  });
778
904
  });
779
905
  }
906
+
907
+ // ─── 面板空闲自释放所需的观测接口(panel-guard.js 调用)────────────
908
+
909
+ // 当前有多少主题「真的在等待」——常驻面板的空闲自释放据此否决退出:
910
+ // 有 agent 正阻塞等人时,面板是唯一提醒来源,绝不能在此时释放。
911
+ //
912
+ // waiting 标志由 wait 进程写入、结束复位,跨进程可见;但 wait 被强杀(SIGKILL)时
913
+ // 标志会残留。所以再过一道「截止时刻」:waitingUntil 已过期视为不在等待——那一刻
914
+ // wait 本来也要收尾了,不会误伤正常等待。
915
+ export async function countWaitingTopics() {
916
+ const store = await loadStore();
917
+ const now = Date.now();
918
+ let n = 0;
919
+ for (const t of Object.values(store.topics)) {
920
+ if (!t || !t.waiting) continue;
921
+ const until = parseStampMs(t.waitingUntil);
922
+ if (Number.isFinite(until) && until <= now) continue; // 残留标志:截止时刻已过
923
+ n += 1;
924
+ }
925
+ return n;
926
+ }
927
+
928
+ // "YYYY-MM-DD HH:mm:ss"(本地)→ epoch ms;无法解析返回 NaN
929
+ function parseStampMs(s) {
930
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(String(s || ''));
931
+ if (!m) return NaN;
932
+ return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime();
933
+ }
934
+
935
+ // 活动 SSE 面板连接(只在常驻 serve 进程内有意义)。每条记录最近一次成功写入的时间,
936
+ // wait 据此判断是否真有「活着的」面板标签:心跳写失败(标签已关、TCP 残留)会立刻剔除,
937
+ // 超过新鲜窗口没成功写过的连接不计入——避免页面关了却误判在线、整轮零提醒也不开页。
938
+ //
939
+ // 本期只交付「存在性」接口:panel-guard 只要 clients 数字。SSE 事件流是更上层能力,
940
+ // 留到下个 release 再叠加;这里只保留最小数据结构,streamEventsSSE 接入时直接复用。
941
+ const SSE_HEARTBEAT_MS = 15000;
942
+ const SSE_LIVE_WINDOW_MS = 30000;
943
+ const sseConns = new Set();
944
+ export function panelPresence() {
945
+ const now = Date.now();
946
+ let clients = 0;
947
+ for (const conn of sseConns) {
948
+ if (now - conn.lastOkWrite <= SSE_LIVE_WINDOW_MS) clients += 1;
949
+ }
950
+ return { clients, total: sseConns.size };
951
+ }
952
+
953
+ // 给 runtime/api.js 用的最小 SSE 注册表:每次「/api/localtasks/events」连接时
954
+ // 调一下 addEventStreamConn(req, res),把连接纳入 panelPresence 统计。
955
+ export function addEventStreamConn(req, res) {
956
+ const conn = { lastOkWrite: Date.now() };
957
+ sseConns.add(conn);
958
+ const beat = setInterval(() => {
959
+ try {
960
+ res.write(`: ping ${Date.now()}\n\n`);
961
+ conn.lastOkWrite = Date.now();
962
+ } catch {
963
+ clearInterval(beat);
964
+ sseConns.delete(conn);
965
+ }
966
+ }, SSE_HEARTBEAT_MS);
967
+ beat.unref?.();
968
+ const cleanup = () => {
969
+ clearInterval(beat);
970
+ sseConns.delete(conn);
971
+ };
972
+ req.on('close', cleanup);
973
+ req.on('error', cleanup);
974
+ res.on('close', cleanup);
975
+ return conn;
976
+ }
@@ -21,6 +21,21 @@ function countdown(s) {
21
21
  return `等待中 ${mm}:${String(ss).padStart(2, '0')}`;
22
22
  }
23
23
 
24
+ // 「已等 m:ss · 剩 m:ss」:waitingSince(起点)反推已等,waitingUntil(截止)反推剩余。
25
+ // waitingSince 缺字段时(老数据)退化为只显示剩余。
26
+ function waitingBadge(t) {
27
+ if (!t || !t.waiting) return null;
28
+ const remain = countdown(t.waitingUntil);
29
+ if (!t.waitingSince) return remain; // 老数据兜底
30
+ const since = parseStamp(t.waitingSince);
31
+ if (!since) return remain;
32
+ const elapsedMs = Date.now() - since.getTime();
33
+ if (elapsedMs < 0) return remain;
34
+ const mm = Math.floor(elapsedMs / 60000);
35
+ const ss = Math.floor((elapsedMs % 60000) / 1000);
36
+ return `等待中 已等 ${mm}:${String(ss).padStart(2, '0')} · 剩 ${remain.replace(/^等待中\s*/, '')}`;
37
+ }
38
+
24
39
  export default function SessionTaskView() {
25
40
  const toast = useToast();
26
41
  const guard = useGuard();
@@ -32,6 +47,13 @@ export default function SessionTaskView() {
32
47
  const [timeoutSec, setTimeoutSec] = useState(180);
33
48
  const [cfgSaving, setCfgSaving] = useState(false);
34
49
  const [pruning, setPruning] = useState(false);
50
+ // 收尾追加语:task.text 末尾自动追加的固定指令(强制 agent 计划最后再 wait 一次)
51
+ const [taskSuffix, setTaskSuffix] = useState('');
52
+ const [defaultTaskSuffix, setDefaultTaskSuffix] = useState('');
53
+ const [suffixEnabled, setSuffixEnabled] = useState(false);
54
+ const [suffixEditing, setSuffixEditing] = useState(false);
55
+ const [suffixDraft, setSuffixDraft] = useState('');
56
+ const [suffixSaving, setSuffixSaving] = useState(false);
35
57
  const [editingId, setEditingId] = useState(null);
36
58
  const [editText, setEditText] = useState('');
37
59
  const [editSaving, setEditSaving] = useState(false);
@@ -67,7 +89,12 @@ export default function SessionTaskView() {
67
89
 
68
90
  // 同步页面上的超时配置(来源:后端 settings)
69
91
  useEffect(() => {
70
- if (overview && overview.settings) setTimeoutSec(overview.settings.timeoutSec);
92
+ if (overview && overview.settings) {
93
+ setTimeoutSec(overview.settings.timeoutSec);
94
+ setTaskSuffix(overview.settings.taskSuffix || '');
95
+ setDefaultTaskSuffix(overview.settings.defaultTaskSuffix || '');
96
+ setSuffixEnabled(!!overview.settings.suffixEnabled);
97
+ }
71
98
  }, [overview]);
72
99
 
73
100
  const saveTimeout = (sec) =>
@@ -86,6 +113,30 @@ export default function SessionTaskView() {
86
113
  }
87
114
  });
88
115
 
116
+ // 收尾追加语:保存(draft 是 '' 等价于关闭;非空则启用)
117
+ const saveSuffix = (text) =>
118
+ guard(async () => {
119
+ setSuffixSaving(true);
120
+ try {
121
+ const s = await api('/api/localtasks/setting', {
122
+ method: 'PATCH',
123
+ body: { suffix: text },
124
+ });
125
+ setTaskSuffix(s.taskSuffix);
126
+ setSuffixEnabled(!!s.suffixEnabled);
127
+ setSuffixEditing(false);
128
+ setSuffixDraft('');
129
+ toast(s.suffixEnabled ? '收尾追加语已更新' : '收尾追加语已关闭');
130
+ await load();
131
+ } finally {
132
+ setSuffixSaving(false);
133
+ }
134
+ });
135
+
136
+ // 显式关闭(不进入编辑,直接 PATCH suffix='')
137
+ const disableSuffix = () =>
138
+ saveSuffix('');
139
+
89
140
  // 清理旧脏数据(旧模型无目录主题、空主题;等待中保留)
90
141
  const prune = () =>
91
142
  guard(async () => {
@@ -191,6 +242,22 @@ export default function SessionTaskView() {
191
242
  }
192
243
  });
193
244
 
245
+ // 关闭某主题的等待:只翻 store 里的 cancelRequested 标记,阻塞中的 wait 立即收尾。
246
+ // 不删主题、不删任务、不动队列——历史照留,只是当前这一轮不等了。
247
+ const cancelTopic = (t) =>
248
+ guard(async () => {
249
+ if (!t || !t.waiting) return;
250
+ const msg = `关闭主题「${t.name}」的等待?\n\n阻塞中的 wait 立即收尾(走与超时同一条出口,agent 照常结束本轮),主题与任务都保留。`;
251
+ if (!window.confirm(msg)) return;
252
+ const r = await api('/api/localtasks/cancel', {
253
+ method: 'POST',
254
+ body: { key: t.key },
255
+ });
256
+ if (r && r.cancelled) toast('已关闭等待');
257
+ else toast('该主题不在等待中');
258
+ await load();
259
+ });
260
+
194
261
  // 批量删除勾选主题;含等待中时 force 并提示
195
262
  const batchDelete = () =>
196
263
  guard(async () => {
@@ -328,6 +395,96 @@ export default function SessionTaskView() {
328
395
  </span>
329
396
  </div>
330
397
 
398
+ <div className="card" style={{ padding: '10px 14px' }}>
399
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: suffixEditing ? 10 : 0 }}>
400
+ <span style={{ fontWeight: 700 }}>收尾追加语</span>
401
+ <span className="muted" style={{ fontSize: 12 }}>
402
+ 每次 wait 返回任务时,自动拼到 task.text 末尾。强制 agent 计划最后再 wait 一次,避免长对话漂移漏调。
403
+ </span>
404
+ <span style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
405
+ <span className={'tag' + (suffixEnabled ? '' : ' muted')} style={{ fontSize: 11 }}>
406
+ {suffixEnabled ? '已开启' : '已关闭'}
407
+ </span>
408
+ {!suffixEditing && (
409
+ <>
410
+ {suffixEnabled && (
411
+ <button className="btn small ghost" onClick={() => { setSuffixDraft(taskSuffix); setSuffixEditing(true); }}>
412
+ 编辑
413
+ </button>
414
+ )}
415
+ {suffixEnabled ? (
416
+ <button
417
+ className="btn small ghost"
418
+ onClick={() => disableSuffix()}
419
+ disabled={suffixSaving}
420
+ title="关闭后,wait 返回的任务不再追加提示词"
421
+ >
422
+ 关闭
423
+ </button>
424
+ ) : (
425
+ <button
426
+ className="btn small"
427
+ onClick={() => { setSuffixDraft(defaultTaskSuffix); setSuffixEditing(true); }}
428
+ title="恢复默认收尾追加语并开启"
429
+ >
430
+ 开启
431
+ </button>
432
+ )}
433
+ </>
434
+ )}
435
+ </span>
436
+ </div>
437
+ {!suffixEditing && taskSuffix && (
438
+ <div
439
+ className="muted"
440
+ style={{ fontSize: 12, whiteSpace: 'pre-wrap', borderLeft: '2px solid #eee', padding: '4px 0 4px 10px' }}
441
+ >
442
+ {taskSuffix}
443
+ </div>
444
+ )}
445
+ {suffixEditing && (
446
+ <div>
447
+ <textarea
448
+ value={suffixDraft}
449
+ onChange={(e) => setSuffixDraft(e.target.value)}
450
+ rows={5}
451
+ style={{ width: '100%', font: 'inherit', padding: 8, boxSizing: 'border-box' }}
452
+ placeholder="(留空 = 关闭;非空 = 启用并以这段文本作为追加语)"
453
+ />
454
+ <div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center', flexWrap: 'wrap' }}>
455
+ <button
456
+ className="btn small"
457
+ onClick={() => saveSuffix(suffixDraft)}
458
+ disabled={suffixSaving}
459
+ >
460
+ 保存
461
+ </button>
462
+ <button
463
+ className="btn small ghost"
464
+ onClick={() => {
465
+ setSuffixEditing(false);
466
+ setSuffixDraft('');
467
+ }}
468
+ disabled={suffixSaving}
469
+ >
470
+ 取消
471
+ </button>
472
+ <button
473
+ className="btn small ghost"
474
+ onClick={() => saveSuffix(defaultTaskSuffix)}
475
+ disabled={suffixSaving}
476
+ title="覆盖为默认文案并开启"
477
+ >
478
+ 恢复默认
479
+ </button>
480
+ <span className="muted" style={{ fontSize: 11, marginLeft: 'auto' }}>
481
+ 留空保存 = 关闭
482
+ </span>
483
+ </div>
484
+ </div>
485
+ )}
486
+ </div>
487
+
331
488
  <div className="colhead">
332
489
  <input
333
490
  type="checkbox"
@@ -404,9 +561,9 @@ export default function SessionTaskView() {
404
561
  现状:{t.summary}
405
562
  </div>
406
563
  )}
407
- <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
564
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
408
565
  {t.waiting ? (
409
- <span className="tag strong">{countdown(t.waitingUntil)}</span>
566
+ <span className="tag strong">{waitingBadge(t) || countdown(t.waitingUntil)}</span>
410
567
  ) : (
411
568
  <span className="tag">已结束</span>
412
569
  )}
@@ -415,9 +572,18 @@ export default function SessionTaskView() {
415
572
  </span>
416
573
  </div>
417
574
  <div
418
- style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}
575
+ style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 8 }}
419
576
  onClick={(e) => e.stopPropagation()}
420
577
  >
578
+ {t.waiting && (
579
+ <button
580
+ className="btn small ghost"
581
+ onClick={() => cancelTopic(t)}
582
+ title="关闭本次等待(阻塞中的 wait 立即收尾,主题与任务保留)"
583
+ >
584
+ 关闭
585
+ </button>
586
+ )}
421
587
  <button
422
588
  className="btn small ghost"
423
589
  onClick={() => deleteTopic(t)}
@@ -21,7 +21,16 @@ const BUILTINS = [
21
21
  id: 'serve',
22
22
  cli: ['serve'],
23
23
  summary: '启动 Web 面板',
24
- flags: { port: { type: 'number', default: DEFAULT_PORT }, 'no-open': { type: 'boolean' } },
24
+ flags: {
25
+ port: { type: 'number', default: DEFAULT_PORT },
26
+ 'no-open': { type: 'boolean' },
27
+ // --auto-exit:面板(web)全关且无等待中的主题时,serve 自动退出。
28
+ // 用户手动 `nx-kv serve` 默认不带该标志,面板不会自己跑掉;
29
+ // wait 临时拉起 serve 时会加 --no-open --auto-exit,用完即让位。
30
+ 'auto-exit': { type: 'boolean', hint: '面板关闭后空闲自释放(wait 临时拉起实例专用)' },
31
+ // 宽限期:连续多久无面板活动后才允许释放(与 --auto-exit 配合使用)
32
+ idle: { type: 'number', hint: '空闲宽限期(秒;与 --auto-exit 配合,默认 120)' },
33
+ },
25
34
  run: (ctx) => cmdServe(ctx),
26
35
  },
27
36
  {
@@ -245,15 +254,22 @@ function printCommandHelp(cmd) {
245
254
 
246
255
  async function cmdServe(ctx) {
247
256
  const { startServer } = await import('./server.js');
257
+ const { startPanelIdleGuard } = await import('../modules/localtask/panel-guard.js');
248
258
  const { openBrowser } = await import('../core/open.js');
249
259
  const port = ctx.port || DEFAULT_PORT;
250
- const server = await startServer({ port, host: '127.0.0.1' });
260
+ const autoExit = !!ctx['auto-exit'];
261
+
262
+ // 活动观测钩子:每个进来的请求(静态或 /api)都回调一次,供空闲自释放判活。
263
+ // 不参与响应,异常必须吞掉。
264
+ const onRequest = () => {};
265
+ const server = await startServer({ port, host: '127.0.0.1', onRequest });
251
266
  const addr = `http://127.0.0.1:${server.address().port}`;
252
267
 
253
268
  console.log('nx-kv · nx-kv v' + VERSION);
254
269
  console.log(`面板: ${addr}`);
255
270
  console.log(`配置: ${configPathFromEnv()}`);
256
271
  console.log('CLI: nx-kv help(每个按钮都有对应命令,agent 可加 --json)');
272
+ if (autoExit) console.log('模式: 空闲自释放(面板关闭且无等待中的主题后自动退出)');
257
273
  console.log('按 Ctrl+C 停止');
258
274
 
259
275
  if (!ctx['no-open']) openBrowser(addr);
@@ -264,6 +280,11 @@ async function cmdServe(ctx) {
264
280
  };
265
281
  process.on('SIGINT', shutdown);
266
282
  process.on('SIGTERM', shutdown);
283
+
284
+ // 启动空闲看门狗(仅 --auto-exit 模式);空回调避免依赖具体参数
285
+ if (autoExit) {
286
+ startPanelIdleGuard({ server, idleSec: ctx.idle });
287
+ }
267
288
  }
268
289
 
269
290
  // ---- 主入口 ----
@@ -50,10 +50,26 @@ async function serveStatic(pathname, res) {
50
50
  }
51
51
  }
52
52
 
53
- export function startServer({ port = DEFAULT_PORT, host = '127.0.0.1' } = {}) {
53
+ // /api/ping:纯本机快返回,供面板心跳与「面板还开着吗」观测。
54
+ // 不触网、不读盘、不查登录态——只为测「端口上还活着」。这与 /api/health 不同:
55
+ // /api/health 走的是更重的鉴权/状态检查,不适合每 15s 一次的高频探活。
56
+ async function handlePing(_req, res) {
57
+ sendJson(res, 200, { ok: true, data: { pong: true, ts: Date.now() } });
58
+ }
59
+
60
+ export function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', onRequest } = {}) {
54
61
  const server = http.createServer(async (req, res) => {
62
+ // 活动观测:每个请求(包括 /api/ping 与静态文件)都回调一次;
63
+ // 回调只上报,不参与响应;异常必须吞掉以免影响主流程。
64
+ if (typeof onRequest === 'function') {
65
+ try { onRequest(req); } catch { /* ignore */ }
66
+ }
55
67
  try {
56
68
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
69
+ if (url.pathname === '/api/ping') {
70
+ await handlePing(req, res);
71
+ return;
72
+ }
57
73
  if (url.pathname.startsWith('/api/')) {
58
74
  await handleApi(req, res, url);
59
75
  return;
@@ -4,6 +4,7 @@ import { Suspense, useEffect } from 'react';
4
4
  import { useStore } from './store.jsx';
5
5
  import { ErrorBoundary } from './components/ui.jsx';
6
6
  import { VIEWS } from './registry.js';
7
+ import { api } from './api/client.js';
7
8
 
8
9
  function viewFromHash() {
9
10
  const h = (location.hash || '').replace(/^#\/?/, '');
@@ -31,6 +32,24 @@ export default function App() {
31
32
  return () => window.removeEventListener('hashchange', onHash);
32
33
  }, [patchUi]);
33
34
 
35
+ // 心跳:每 15s 打一次 /api/ping。
36
+ // 目的不是健康检查,而是给 serve --auto-exit 上报「面板还开着」。
37
+ // 单视图挂载(current.component)切到任意页面都算「开着」,
38
+ // 不用 SSE 数连接来判——切页会断 SSE,但页面明明还开着。
39
+ useEffect(() => {
40
+ let stopped = false;
41
+ const beat = () => {
42
+ if (stopped) return;
43
+ api('/api/ping').catch(() => {});
44
+ };
45
+ beat();
46
+ const t = setInterval(beat, 15_000);
47
+ return () => {
48
+ stopped = true;
49
+ clearInterval(t);
50
+ };
51
+ }, []);
52
+
34
53
  const current = VIEWS.find((v) => v.id === ui.view) || VIEWS[0];
35
54
 
36
55
  const switchTo = (id) => {