nx-kv 0.8.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.8.1] - 2026-10-12
6
+
7
+ ### Added
8
+
9
+ - **常驻面板空闲自释放(serve --auto-exit)**:面板(web)全关且无等待中的主题时,
10
+ serve 进程优雅退出,释放端口——避免后台留一个永远没人看的常驻进程。
11
+ - `nx-kv serve` 新增 `--auto-exit`(配 `--idle <秒>` 调宽限期,默认 120s)。开启后,
12
+ **面板页全部关闭、且没有任何等待中的主题**并持续超过宽限期时,进程优雅退出、释放端口;
13
+ 下一次 `localtask wait` 需要时会自动重新拉起(冷启动 <1s)。**用户手动 `nx-kv serve`
14
+ 默认不带该标志,面板不会自己跑掉**。
15
+ - 判定「面板是否还开着」用的是任意面板请求的最近时间(SSE 连接数会随切页归 0,
16
+ 不能用),为此新增 `GET /api/ping`(纯本机快返回)并在面板壳层每 15s 打一次心跳,
17
+ 任意页面停留都算「面板开着」。
18
+ - 两条硬性否决:**有等待中的主题**(agent 正阻塞等人,面板是唯一提醒源)与
19
+ **有活动 SSE 标签**时不释放;`waiting` 标志若已过 `waitingUntil` 截止时刻(wait
20
+ 进程被强杀后的残留)不计入,避免残留标志让进程永不释放。
21
+ - 从未有面板客户端连过(无 GUI / 开页失败)时用更长的兜底上限(默认 600s),
22
+ 避免与 wait 的「无标签就重开页」互相拉扯出 spawn 抖动。
23
+ - 新增 `src/modules/localtask/panel-guard.js`(纯判定 `shouldAutoExit` + 可注入
24
+ 时钟/定时器的看门狗 `startPanelIdleGuard`)与 `countWaitingTopics()`。
25
+
26
+ - **主题卡「等待中」勋章显示等了多久,并可一键「关闭」这个等待**:
27
+ - 勋章文案由「等待中 m:ss(剩余)」升级为 **`等待中 已等 m:ss · 剩 m:ss`**:
28
+ 新增 `waitingSince`(等待起点)落盘——只靠 `waitingUntil`(截止时刻)反推不出起点,
29
+ 因为超时上限本身可被改写;旧记录缺该字段时自动退化为只显示剩余。
30
+ - 勋章内新增 **「关闭」按钮**:点确认后**只关等待**——阻塞中的 `wait` 立即收尾并返回
31
+ `{status:"timeout", cancelled:true}`(走与超时同一条出口,agent 侧不必认识新状态,
32
+ 照常结束本轮),**不删主题、不删任务、不动队列**。
33
+ - 新增 action `localtask cancel`(CLI `nx-kv localtask cancel --cwd/--key`,
34
+ HTTP `POST /api/localtasks/cancel`);对「不在等待中」的主题是无操作
35
+ (`{cancelled:false, reason:"not_waiting"}`),不报错。
36
+ - 关闭标记(`cancelRequested`)**只生效一次**:`setWaiting` 在每次等待开始时清掉残留,
37
+ 避免「用户点关闭」与「上一轮 wait 收尾」撞车后误杀下一轮等待;wait 端每 0.5s 轮询取走,
38
+ 另在等待登记完成后立即复查一次,不让用户白等半秒。
39
+
5
40
  ## [0.8.0] - 2026-10-11
6
41
 
7
42
  ### Added
package/README.md CHANGED
@@ -74,7 +74,7 @@ remove-many`)。阻塞等待弹窗只是 `wait` 当前的一个子功能。三
74
74
  同一条队列、一套存储:
75
75
 
76
76
  ```bash
77
- # 等待侧:agent 本轮【最后一步】阻塞等浏览器回填(工具调用超时设 300000ms)
77
+ # 等待侧:每完成一步、提交结果后【必须】调 wait 领新任务(循环只有 timeout 才终止)
78
78
  nx-kv localtask wait --summary "<当前现状总结>" --json
79
79
  # 队列非空 → 立即取走(queued:true);空 → 开窗阻塞,默认 180 秒未回填 → {status:"timeout"}(exit 0)
80
80
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kv-submit
3
- description: 把当前对话里的需求打包成任务,通过 nx-kv localtask add 提交到另一个工作目录的队列,委托在那里的 agent 完成——先总结上下文(聊了什么、定了什么、涉及哪些文件)、再提炼出用户的真实需求(目标、约束、验收标准),写成不依赖本对话就能执行的自包含任务文本再提交;可给正在等待的 agent 实时补任务,也可给不在等待的目录排队。触发词:提交任务、委托、派任务、丢给另一个 agent、交给那边做、转发需求、整理成任务提交、总结上下文提交、提炼需求、localtask add、kv-submit。凡「把这个对话里的东西变成任务交给别的 agent」的场景都走本 skill。反例:你自己在等用户回填用 kv-waittask;主动领取排给本目录的任务用 kv-localget;操作远程 KV 清单(todo)用 nx-kv。
3
+ description: 把当前对话里的需求打包成任务,通过 nx-kv localtask add 提交到另一个工作目录的队列,委托在那里的 agent 完成——先总结上下文(聊了什么、定了什么、涉及哪些文件)、再提炼出用户的真实需求(目标、约束、验收标准),写成不依赖本对话就能执行的自包含任务文本再提交;可给正在等待的 agent 实时补任务,也可给不在等待的目录排队。触发词:提交任务、委托、派任务、丢给另一个 agent、交给那边做、转发需求、整理成任务提交、总结上下文提交、提炼需求、长对话提炼、保存会话、会话交接、localtask add、kv-submit。凡「把这个对话里的东西变成任务交给别的 agent」的场景都走本 skill。反例:你自己在等用户回填用 kv-waittask;主动领取排给本目录的任务用 kv-localget;操作远程 KV 清单(todo)用 nx-kv。
4
4
  ---
5
5
 
6
6
  # kv-submit — 提炼需求,委托其它 agent(提交侧)
@@ -13,7 +13,9 @@ description: 把当前对话里的需求打包成任务,通过 nx-kv localtask
13
13
  ## 三步走(核心流程)
14
14
 
15
15
  1. **总结上下文**:当前对话聊了什么、做了哪些决策、涉及哪些文件/路径/约定。
16
- 只挑与需求相关的,不搬流水账。
16
+ 只挑与需求相关的,不搬流水账。会话很长、或要把会话保存下来交给其他
17
+ agent 时,先按 [[context-layering]] 分层组织(旧对话结构化摘要 + 最近
18
+ 对话逐字保留),再挑。
17
19
  2. **提炼用户需求**:把用户散落的表述收敛成一句明确的目标 + 验收标准;
18
20
  有歧义的先向用户确认,不要把猜测提交出去。
19
21
  3. **打包提交**:写成自包含任务文本(模板见 [[submit-workflow]]),投给目标目录:
@@ -0,0 +1,121 @@
1
+ # context-layering — 长会话的分层上下文组织 SOP
2
+
3
+ > 归属 [[kv-submit]]。会话已经拉得很长(几十上百轮、关键信息散落各处),
4
+ > 要把会话保存下来交给其他 agent、或从中提炼需求时,先按本 SOP 组织上下文,
5
+ > 再走 [[submit-workflow]] 提交。
6
+
7
+ ## 一、交接 = 换一个没有本会话记忆的读者
8
+
9
+ kv-submit 的本质是**会话交接**:接收方看不到本对话,任务文本必须把上下文
10
+ 重新组织成它能独立使用的形态。这和 agent 延续自己的会话是同一个问题,
11
+ 答案也一样:**分层组织,不要单层平铺**。
12
+
13
+ 单层的两种翻车:
14
+
15
+ - 全搬流水账 → 接收方抓不住重点,对话里独有的信息被淹没;
16
+ - 只搬最近几轮 → 早期定下的决策/约束丢失,接收方做出早被否决的方案。
17
+
18
+ 分层的解法:
19
+
20
+ ```
21
+ [旧对话 E1…E8][最近对话 E9 E10] → [结构化摘要层 S1] + [最近原文层 E9 E10]
22
+ ```
23
+
24
+ - **摘要层**(旧对话):按固定模板组织成结构化事实,保证记忆槽位不缺;
25
+ - **原文层**(最近对话):最近一两轮**逐字保留**——用户需求的原始措辞是
26
+ 第一手证据,不经转述。
27
+
28
+ 两层各管一件事:摘要层保结构齐全,原文层保措辞保真。
29
+
30
+ ## 二、摘要层:七个固定槽位
31
+
32
+ 摘要不是自由发挥的笔记,是**固定槽位的结构化事实**——每个槽位回答一个
33
+ 交接必备的问题,缺一个槽位就缺一类记忆:
34
+
35
+ | 槽位 | 回答的问题 |
36
+ | --- | --- |
37
+ | 目标 | 用户到底想完成什么(一两句) |
38
+ | 需求与约束 | 用户明说过的约束、偏好、范围边界 |
39
+ | 已定决策 | 定下来了什么、为什么 |
40
+ | 工作状态 | 做到哪了:已完成 / 进行中 / 被阻塞 |
41
+ | 下一步 | 接下来按什么顺序做什么 |
42
+ | 相关文件 | 接手要打开哪些路径,写全路径 + 为什么相关(最多 15 条,最重要的在前) |
43
+ | 关键上下文 | 什么事实缺了就干不了、对方又无法自行恢复 |
44
+
45
+ 组织规则:
46
+
47
+ | 规则 | 做法 |
48
+ | --- | --- |
49
+ | 单行短句要点 | 不写段落、不嵌套列表 |
50
+ | 引用代替复述 | 对方能从代码/文件恢复的信息不搬,列路径即可 |
51
+ | 精确保留原文 | 路径、符号名、命令、报错字符串、URL、标识符一字不改 |
52
+ | 只带未解决的 | 用户问题:已被解决的删掉;未解决的保留原话措辞 |
53
+ | 带上工作流状态 | 未提交/已提交/已推送/在 review/已合并——决定对方从哪接手 |
54
+ | 环境信息不搬 | 仓库通用约定、CLAUDE.md/AGENTS.md 内容对方自己有;**对话独有的决策与偏好必须搬** |
55
+ | 输出只搬关键行 | 日志/命令输出摘关键结论,不整段贴 |
56
+ | 已否决 ≠ 已决策 | 被推翻的旧方案标注「已否决」或丢弃,不得混进「已定决策」 |
57
+ | 文本自包含 | 不出现「如前所述」「上文提到」——交接文本里没有「上文」 |
58
+
59
+ ## 三、原文层:最近对话逐字保留
60
+
61
+ - 最近 1-2 轮对话(用户消息 + 助手回复)原样保留,不进摘要层;
62
+ - 用户最后几条消息的**原话**最重要——需求的第一手证据。进一次摘要是一次
63
+ 转述,组装进任务文本又是一次转述,两层转述就跑偏了;
64
+ - 拿不准某句是决策还是闲聊,回对话原文核对,不靠记忆复述。
65
+
66
+ ## 四、交接层:两层组装成任务文本
67
+
68
+ 任务文本是交给接收方的**交接层**,从上面两层逐段组装(①目标②背景③注意,单行):
69
+
70
+ | 来源 | 去向 |
71
+ | --- | --- |
72
+ | 摘要层·目标 + 下一步 | ①目标 |
73
+ | 摘要层·需求与约束 + 已定决策 + 相关文件 + 关键上下文 | ②背景 |
74
+ | 摘要层·工作状态·已完成 | ②背景里写「已完成,不要重做」,或无必要则省略 |
75
+ | 摘要层·工作状态·被阻塞 + 已否决方案 | ③注意 |
76
+ | 原文层·未回答的用户问题(原话) | ③注意,标注「用户尚未回答」 |
77
+
78
+ 逐段组装,不跳段——跳一段就是丢一块记忆。
79
+
80
+ ## 五、交接文本的提示词构建
81
+
82
+ 把两层组装成任务文本时,按四条要领构建——它们决定接收方拿到文本后
83
+ 能不能正确「接」:
84
+
85
+ 1. **开篇重定义任务**:第一句就让接收方明白这是**接手**,不是新开工。
86
+ ①目标开头交代已有进度(「继续/接手…,已完成 X」),防止它从零重做。
87
+ 2. **固定槽位 = 输出契约**:七槽位就是契约,缺一个槽位就是缺一类记忆。
88
+ 组装完对照第七节清单逐段核对,缺了就补,不带侥幸提交。
89
+ 3. **消费侧说明**:明写接收方该怎么用这份交接——「已完成 X,不要重做」;
90
+ 未答问题标「用户尚未回答」;带原文引用的地方是第一手证据,可放心依据。
91
+ 4. **禁区前置**:③注意先写「不要做什么」,再写坑——禁令比描述更防跑偏。
92
+
93
+ ## 六、会话继续增长后再组织
94
+
95
+ 交接不是一次性的:给同一个目标分多轮补任务、或会话又聊了一段需要重新
96
+ 组织时,按同一套架构迭代:
97
+
98
+ - **新事实永远优先于旧摘要**:旧信息保留,除非新对话明确推翻、取代或解决了它;
99
+ - 重新对工作状态:完成的从「进行中」移出;已解决的阻塞、已回答的问题删掉;未解决的继续保留;
100
+ - 已不再服务于「接着干」的信息可以删——组织为续做服务,不为存档服务。
101
+
102
+ ## 七、交接前校验清单
103
+
104
+ - [ ] 路径全部是全路径且真实存在
105
+ - [ ] 报错/命令/符号名与对话原文逐字一致
106
+ - [ ] 「已定决策」里没有混入被否决方案
107
+ - [ ] 用户未回答的问题原话保留并标注
108
+ - [ ] 工作流状态(提交/推送与否)写清了
109
+ - [ ] 「如上所述」「刚才那个」零出现
110
+ - [ ] 给用户过目后再 `localtask add`
111
+
112
+ ## 八、常见错误
113
+
114
+ | 错误 | 后果 | 正确做法 |
115
+ | --- | --- | --- |
116
+ | 会话很长还直接提炼,不分层 | 早期决策/约束静默丢失 | 先分两层(摘要 + 原文),再组装任务文本 |
117
+ | 摘要层写成流水账 | 任务文本超长,接收方抓不住重点 | 七个固定槽位 + 单行要点,无关支线不搬 |
118
+ | 报错/路径凭记忆转述 | 对方按错误信息排查半天 | 精确保留原文,拿不准回对话核对 |
119
+ | 用户原话被两轮转述 | 需求失真,做出来的不是要的 | 最近对话逐字保留,未答问题带原话 |
120
+ | 已否决方案混进背景 | 对方做出被否决的方案 | 已否决的进 ③注意 或丢弃 |
121
+ | AGENTS.md/通用约定大段搬进任务 | 文本膨胀,独有信息被稀释 | 只搬对话独有的决策、偏好、事实 |
@@ -25,6 +25,10 @@
25
25
 
26
26
  流水账、被否决的方案、与本需求无关的支线——都不要带。
27
27
 
28
+ 会话已经很长(几十上百轮)、或要把会话保存下来交给其他 agent 时,直接挑
29
+ 容易丢早期决策——先按 [[context-layering]] 分层组织(旧对话结构化摘要 +
30
+ 最近对话逐字保留),再提炼。
31
+
28
32
  ### 2. 提炼用户需求(收敛成一句目标)
29
33
 
30
34
  把用户分散的、口语化的表述收敛成:
@@ -1,27 +1,30 @@
1
1
  ---
2
2
  name: kv-waittask
3
- description: 在一个对话里通过工具调用实时接收任务(本机人机接力),替代在原始输入框打字——agent 完成当前任务、提交结果后,把 nx-kv localtask wait --summary "<当前现状总结>" 作为本轮最后一步强制调用;命令阻塞并自动打开浏览器弹窗,用户在面板看清现状、针对该现状回填后解除阻塞、把新任务作为工具结果返回,agent 继续;队列按工作目录划分,同目录的任务(含别人委托排队的)按序领取;超时未回填则收尾结束本轮。触发词:本地任务、下一条任务、继续给任务、连续提交任务、补充任务、回填任务、现状总结、在网页/弹窗里给任务、阻塞等待、挂起等用户、人机接力、等用户喂任务、localtask wait、localtask(旧称 sessiontask)、kv-waittask。凡是「agent 不结束本轮、靠一个阻塞工具调用等浏览器里继续喂任务」的场景,都走本 skill。反例:要「主动领取排给本目录的委托任务」用 kv-localget;要「把需求打包提交给别的 agent」用 kv-submit;要操作远程 KV 清单(todo)用 nx-kv。
3
+ description: 在一个对话里通过工具调用实时接收任务(本机人机接力),替代在原始输入框打字——每完成一步、提交结果后,都必须把 nx-kv localtask wait --summary "<当前现状总结>" 作为该步的结束动作强制调用,领取新任务(循环不自行终止,只有 timeout 才能结束本轮);命令阻塞并自动打开浏览器弹窗,用户在面板看清现状、针对该现状回填后解除阻塞、把新任务作为工具结果返回,agent 继续;队列按工作目录划分,同目录的任务(含别人委托排队的)按序领取。触发词:本地任务、下一条任务、继续给任务、连续提交任务、补充任务、回填任务、现状总结、在网页/弹窗里给任务、阻塞等待、挂起等用户、人机接力、等用户喂任务、每步结束领新任务、localtask wait、localtask(旧称 sessiontask)、kv-waittask。凡是「agent 不结束本轮、靠一个阻塞工具调用等浏览器里继续喂任务」的场景,都走本 skill。反例:要「主动领取排给本目录的委托任务」用 kv-localget;要「把需求打包提交给别的 agent」用 kv-submit;要操作远程 KV 清单(todo)用 nx-kv。
4
4
  ---
5
5
 
6
6
  # kv-waittask — 对话内本地任务接力(等待侧)
7
7
 
8
- 本质是一个循环:**agent 提交结果 → CLI 阻塞 → 用户在 web 看清现状、回填新任务 →
9
- agent 接收并继续**。在一个对话里连续接收任务,不靠原始输入框。
8
+ 本质是一个强制循环:**每完成一步 → 提交结果 → 必须调用 wait 领取新任务**。
9
+ 循环不自行终止——**每一步的结束动作都只能是 `nx-kv localtask wait`**;「做完
10
+ 就收尾停住」是违规,唯一合法的停止条件是 wait 返回 `timeout`。
10
11
 
11
- agent 完成当前任务、需要下一条输入时,调用一个**阻塞式**工具
12
+ 具体形态:agent 每做完一步、把结果提交后,调用一个**阻塞式**工具
12
13
  `nx-kv localtask wait`,并带上一句**当前现状总结**;它自动打开浏览器弹窗。
13
14
  用户在面板/弹窗一眼看到现状,针对该现状回填,阻塞解除、新任务作为工具结果返回,
14
- agent 接着干。如此循环。别人通过 kv-submit / 面板 / CLI 排进**本目录队列**的任务,
15
- wait 也会按序取走。
15
+ agent 接着干;做完再 wait——如此循环。别人通过 kv-submit / 面板 / CLI 排进
16
+ **本目录队列**的任务,wait 也会按序取走。
16
17
 
17
18
  > 纯本机协调:弹窗由 wait 进程临时起的 http 服务器直出,数据存本机文件
18
19
  > (`~/.nx-kv/localtasks.json`),**不需要登录 KV 后端**。
19
20
 
20
21
  ## 四条硬规则(最重要)
21
22
 
22
- 1. **wait 必须是本轮最后一个动作。** 把它安排在所有分析与工具调用之后(阶段
23
- 结果已经通过正常输出提交);调用之后不要再安排任何别的工具、输出或推理——
24
- 它会阻塞,直到拿到新任务或超时。
23
+ 1. **每一步的结束都必须是 wait——循环强制,不许做完就停。** 每完成一步(结果
24
+ 已通过正常输出提交),立即调用 wait 领取新任务;队列里有排队的任务就直接
25
+ 领走继续干,没有就阻塞等用户回填。把 wait 安排在本轮所有分析与工具调用
26
+ 之后,调用之后不要再安排任何别的工具、输出或推理——它会阻塞,直到拿到
27
+ 新任务或超时。**只有 timeout(第 4 条)能结束这个循环。**
25
28
  2. **这次工具调用的超时设为 `300000` ms(5 分钟)。** CLI 默认阻塞 180 秒、
26
29
  上限 600 秒;5 分钟覆盖默认值并留余量。若显式传了更长的 `--timeout`,
27
30
  工具超时要同步放大到比它更大。
@@ -29,8 +32,9 @@ wait 也会按序取走。
29
32
  等用户决定什么**,让用户不读对话也能在面板快速了解现状;它展示在弹窗/面板
30
33
  顶部,并作为「本轮完成总结」记录在被取走的任务上。它**只是状态展示**——
31
34
  措辞不影响领取顺序,不必刻意保持逐字一致。
32
- 4. **返回 `status:"timeout"` 时直接收尾。** 用户在阻塞时限内没回填,就不要再
33
- 追问、不要再次调用 wait,把当前结论交代清楚、结束本轮即可。
35
+ 4. **返回 `status:"timeout"` 时直接收尾。** 这是循环唯一合法的出口:用户在
36
+ 阻塞时限内没回填,就不要再追问、不要再次调用 wait,把当前结论交代清楚、
37
+ 结束本轮即可。
34
38
 
35
39
  ## 队列模型(一句话版)
36
40
 
@@ -42,7 +46,7 @@ wait 也会按序取走。
42
46
  ## 核心命令
43
47
 
44
48
  ```bash
45
- # 阻塞等待本目录的下一条任务(agent 本轮最后一步;务必加 --json)
49
+ # 阻塞等待本目录的下一条任务——每轮最后一步,强制(务必加 --json)
46
50
  nx-kv localtask wait --summary "<当前现状总结>" --json
47
51
  ```
48
52
 
@@ -60,15 +64,18 @@ nx-kv localtask wait --summary "<当前现状总结>" --json
60
64
  ## 一次循环长什么样
61
65
 
62
66
  ```
63
- agent:干活 → 提交阶段结果
64
- → 最后一步:nx-kv localtask wait --summary "已完成登录,等待选择工作空间" --json
65
- (队列空 → 阻塞,弹窗/面板顶部显示这句现状)
67
+ agent:领到 task.text → 干活 → 提交阶段结果
68
+ → 必须立即:nx-kv localtask wait --summary "已完成登录,等待选择工作空间" --json
69
+ (队列空 → 阻塞,弹窗/面板顶部显示这句现状;队列里有任务 → 直接领走继续)
66
70
  用户:看清现状 → 回填「选择工作空间 A」→ 提交
67
- agent:工具结果拿到 task.text → 继续干活、提交结果
68
- → 需要下一条再 wait……(循环)
69
- 若阻塞时限内没回填:wait 返回 timeout → agent 结束本轮
71
+ agent:工具结果拿到 task.text → 干活 → 提交结果
72
+ → 必须立即再 wait……(循环)
73
+ 若阻塞时限内没回填:wait 返回 timeout → agent 收尾,结束本轮(循环唯一出口)
70
74
  ```
71
75
 
76
+ **每一行的「必须立即」不能省、不能晚、不能换成别的话:** 不调 wait 等于
77
+ 退出循环,队列里排着的任务(别人提交的、用户排队喂的)就没人领。
78
+
72
79
  ## 弹窗与常驻面板
73
80
 
74
81
  - 弹窗顶部「当前现状」显示 wait 传入的现状总结;大输入框 **Enter 提交、
@@ -2,14 +2,18 @@
2
2
 
3
3
  > 归属 [[kv-waittask]]。当你要在一个对话里**不结束本轮**、靠一个阻塞工具
4
4
  > 调用等浏览器里继续回填任务时,按本 SOP 走。
5
+ >
6
+ > **循环是强制的:每完成一步、提交结果后,结束动作只能是 wait——领取新任务。
7
+ > 循环不自行终止,唯一合法的出口是 wait 返回 `timeout`。**
5
8
 
6
9
  ## 一、端到端时序
7
10
 
8
11
  ```
9
12
  1. agent 正常干活(分析、读写文件、调用其它工具)。
10
- 2. 当前任务做完、提交阶段结果,需要下一条输入时:
13
+ 2. 当前任务做完、阶段结果提交后【必须】:
11
14
  → 把「nx-kv localtask wait --summary "<当前现状总结>" --json」
12
- 作为本轮【最后一个动作】调用;工具调用超时设为 300000 ms。
15
+ 作为本轮【最后一个动作】调用,领取新任务;工具调用超时设为 300000 ms。
16
+ 不许做完就收尾停住——「结束动作不是 wait」即为违规。
13
17
  3. wait 的行为:
14
18
  a. 若本目录主题队列已有待领取任务(含 kv-submit 委托排队的)→ 立即取走、
15
19
  不开窗、不阻塞(queued:true);
@@ -17,14 +21,17 @@
17
21
  自动打开浏览器弹窗,然后阻塞。
18
22
  4. 用户在弹窗/面板看清现状,回填新任务 → 提交(Enter 或按钮)。
19
23
  5. wait 解除阻塞,stdout(--json) 返回 {status:"ok", task},进程退出。
20
- 6. agent 从工具结果读 task.text,继续干活、提交结果;做完回到第 2 步——循环。
24
+ 6. agent 从工具结果读 task.text,继续干活、提交结果;做完【必须】回到第 2 步——
25
+ 循环,不许自行中断。
21
26
  7. 若阻塞时限(默认 180 秒)内没有回填:wait 返回 {status:"timeout"}(exit 0),
22
- agent 不再等待,收尾并结束本轮。
27
+ agent 不再等待,收尾并结束本轮——这是整个循环唯一的合法出口。
23
28
  ```
24
29
 
25
- 关键点:**第 2 步之后、工具结果回来之前,agent 不产生任何其它动作。** 用户的
26
- 回填正是通过这个工具结果进入对话的,等于用工具调用替代了在原始输入框打字,
27
- agent 这一轮不会因为「等输入」而提前结束。
30
+ 关键点:**第 2 步不是可选的**——它既是「等用户回填」的手段,也是「领取队列里
31
+ 已排队任务」的手段;不调 wait,排着的任务(kv-submit 委托的、用户提前排队的)
32
+ 就永远没人领。另外,第 2 步之后、工具结果回来之前,agent 不产生任何其它动作。
33
+ 用户的回填正是通过这个工具结果进入对话的,等于用工具调用替代了在原始输入框
34
+ 打字,agent 这一轮不会因为「等输入」而提前结束。
28
35
 
29
36
  ## 二、现状总结怎么写
30
37
 
@@ -129,6 +136,7 @@ wait 必带一句当前现状总结(≤2000 字,连续空白会折叠为单
129
136
 
130
137
  | 错误做法 | 后果 | 正确做法 |
131
138
  | --- | --- | --- |
139
+ | 做完当前任务直接收尾、不调 wait | 强制循环被打破:队列里排着的任务(kv-submit 委托、用户提前排队的)没人领,用户喂的任务石沉大海 | 每一步的结束动作都必须是 wait;只有 timeout 才结束本轮 |
132
140
  | wait 后又安排别的工具/推理 | wait 阻塞,后续动作被卡住、顺序全乱 | wait 严格放在最后一步,之后只处理返回 |
133
141
  | 工具超时没设 / 设成 120000 | CLI 默认阻塞 180 秒,宿主会在阻塞中途杀掉调用 | 工具超时设 `300000` ms;用更长的 `--timeout` 时同步放大 |
134
142
  | wait 不带 / 空着 --summary | 无法展示现状,命令报错退出 | 每次 wait 都写一句现状总结 |
@@ -48,7 +48,7 @@
48
48
 
49
49
  - **动手前必须调用** `superpowers:writing-plans`
50
50
  - 时机:**Step 7 确认主题意图之后、Step 8 开始解决之前**
51
- - 把已确认的意图作为 spec 交给它,产出实现计划(存 `docs/superpowers/plans/YYYY-MM-DD-<feature>-indent.md`),**按计划**再解决
51
+ - 把已确认的意图作为 spec 交给它,产出实现计划(计划存放到哪、怎么命名,由它自己的规范决定),**按计划**再解决
52
52
  - 任务拆分、写法、执行交接都以 `superpowers:writing-plans` 为准,本文件不重复
53
53
 
54
54
  ## 核心流程(8 步)
@@ -114,8 +114,8 @@ nx-kv todo list --status open --topic <topic> --json
114
114
 
115
115
  ### Step 6 — 生成意图文档
116
116
 
117
- 把聚类结果写成结构化文档(如 `.claude/repo/_self/<主题>-意图.md` 或
118
- `docs/superpowers/specs/` 下的设计文档):
117
+ 把聚类结果写成结构化文档,落到**当前项目**的文档目录(如 `docs/<主题>-意图.md`;
118
+ 项目已有设计文档 / spec 目录就放那里):
119
119
 
120
120
  - 每个主题:任务列表、目标、上下文/提示词摘要、边界、验收标准
121
121
  - 文档既是 Step 7 确认的载体,也是后续执行的依据
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,8 @@ function renderAdd(d) {
53
53
  // wait 的人读输出:成功给任务,超时明确提示「本轮结束」
54
54
  function renderWait(r) {
55
55
  if (r.status === 'timeout') {
56
- return `等待超时(${Math.round(r.timeoutMs / 1000)} 秒未回填):主题「${r.cwd}」现状「${r.summary}」结束本轮。`;
56
+ const tail = r.cancelled ? '(用户在面板点了「关闭」)' : '';
57
+ return `等待超时(${Math.round(r.timeoutMs / 1000)} 秒未回填)${tail}:主题「${r.cwd}」现状「${r.summary}」结束本轮。`;
57
58
  }
58
59
  const origin = r.queued ? '排队任务' : '实时回填';
59
60
  return (
@@ -62,6 +63,13 @@ function renderWait(r) {
62
63
  );
63
64
  }
64
65
 
66
+ // cancel 的人读输出:等待中→已关闭(主题与任务保留);非等待中→无操作
67
+ function renderCancel(d) {
68
+ if (d.cancelled) return `已关闭主题「${d.name}」的等待:阻塞中的 wait 立即收尾,主题与任务保留。`;
69
+ if (d.reason === 'not_waiting') return `主题「${d.name || d.key}」不在等待中,无需关闭。`;
70
+ return '已关闭。';
71
+ }
72
+
65
73
  // 批量删除的人读输出:删除清单 + 跳过清单(等待中/不存在)
66
74
  function renderRemoveMany(d) {
67
75
  const lines = [d.all ? `清空:删除 ${d.count} 个主题` : `已删除 ${d.count} 个主题`];
@@ -236,5 +244,18 @@ export default {
236
244
  }),
237
245
  render: renderWait,
238
246
  },
247
+
248
+ {
249
+ id: 'localtask.cancel',
250
+ cli: ['localtask', 'cancel'],
251
+ http: ['POST', '/api/localtasks/cancel'],
252
+ summary: '关闭某主题的等待:阻塞中的 wait 立即收尾(与超时同一出口,带 cancelled 标记;不删主题与任务)',
253
+ flags: {
254
+ cwd: { type: 'string', hint: '工作目录(与 --key 二选一)' },
255
+ key: { type: 'string', hint: '主题 key(与 --cwd 二选一)' },
256
+ },
257
+ run: (ctx) => service.cancelWait({ cwd: ctx.cwd, key: ctx.key }),
258
+ render: renderCancel,
259
+ },
239
260
  ],
240
261
  };
@@ -0,0 +1,155 @@
1
+ // 常驻面板的「空闲自释放」:面板页(web)全关了、又没有等待中的主题时,让 serve
2
+ // 自己退出,释放端口与进程——而不是永远留一个后台常驻进程。
3
+ //
4
+ // 为什么不能直接把「SSE 连接数为 0」当成「web 已关闭」:SSE 只挂在「实时任务」页,
5
+ // 面板是单视图挂载(App.jsx 只渲染 current.component),用户一切到别的页 SSE 就断开,
6
+ // 连接数立刻归 0 —— 页面明明开着,却会被判成「关掉了」。所以判定用**任意面板请求的
7
+ // 最近时间**(App 壳每 15s 打一次 `GET /api/ping`,各页自己的轮询也计入),再叠加两条
8
+ // 硬性否决。
9
+ //
10
+ // 释放条件(全部成立才退出):
11
+ // 1. 处于 --auto-exit 模式(用户手动 `nx-kv serve` 不在其列,不会自己跑掉);
12
+ // 2. 没有任何「等待中」的主题(有 agent 正阻塞等人,面板是唯一提醒来源);
13
+ // 3. 没有活着的面板标签(活动 SSE 连接);
14
+ // 4. 从最后一次面板活动算起,静默超过宽限期——见过面板用短宽限,从未连过(无 GUI、
15
+ // 开页失败)用长宽限,避免和 wait 的「无标签就重开页」互相拉扯成 spawn 抖动。
16
+ //
17
+ // 与 wait 的关系:wait 等待期间主题是 waiting 的 → 第 2 条否决,面板不会释放;
18
+ // wait 结束后若用户把页面关了,才会在宽限期后释放;下一次 wait 再拉起新实例。
19
+ import { countWaitingTopics, panelPresence } from './service.js';
20
+
21
+ export const PANEL_IDLE_DEFAULTS = {
22
+ // 见过面板之后:连续多久没有任何面板请求(且无活标签)就认为「页关掉了」。
23
+ // 取 120s 而非更短,是为了容忍浏览器对后台标签的定时器节流(可被拉到 ~60s 一次)。
24
+ idleMs: 120_000,
25
+ // 从未有过面板客户端(headless / 开页失败):用更长的兜底上限,既不留孤儿进程,
26
+ // 又不至于让 wait 的 20s 重开冷却期反复 spawn 新进程。
27
+ neverSeenIdleMs: 600_000,
28
+ tickMs: 5_000,
29
+ };
30
+
31
+ /**
32
+ * 纯判定:给定一次观测快照,决定是否该释放。便于单测,无副作用。
33
+ * @param {{autoExit:boolean,sawClient:boolean,idleForMs:number,liveClients:number,waitingTopics:number}} snap
34
+ * @param {{idleMs:number,neverSeenIdleMs:number}} [cfg]
35
+ */
36
+ export function shouldAutoExit(snap, cfg = PANEL_IDLE_DEFAULTS) {
37
+ if (!snap || !snap.autoExit) return false;
38
+ if (snap.waitingTopics > 0) return false; // 有人在等回填:面板还有用
39
+ if (snap.liveClients > 0) return false; // 还有活着的面板标签
40
+ const limit = snap.sawClient ? cfg.idleMs : cfg.neverSeenIdleMs;
41
+ return snap.idleForMs >= limit;
42
+ }
43
+
44
+ /**
45
+ * 启动空闲看门狗。返回句柄:`noteActivity()` 供每个请求上报活动,
46
+ * `snapshot()` / `tick()` 暴露出来供测试直接驱动(不必等真实定时器)。
47
+ *
48
+ * @param {object} o
49
+ * @param {object} o.server node http server(用于优雅关闭)
50
+ * @param {number} [o.idleSec] 覆盖宽限期(秒)
51
+ * @param {number} [o.tickMs] 巡检间隔
52
+ * @param {function} [o.now] 取当前时刻(测试可注入假时钟)
53
+ * @param {function} [o.log] 释放时的说明输出
54
+ * @param {function} [o.onExit] 释放动作(缺省 process.exit(0);测试注入以免退出测试进程)
55
+ * @param {function} [o.getWaiting] 读「等待中主题数」
56
+ * @param {function} [o.getLive] 读「活动面板标签数」
57
+ */
58
+ export function startPanelIdleGuard({
59
+ server,
60
+ idleSec,
61
+ tickMs = PANEL_IDLE_DEFAULTS.tickMs,
62
+ now = Date.now,
63
+ log = () => {},
64
+ onExit,
65
+ getWaiting = countWaitingTopics,
66
+ getLive = () => panelPresence().clients,
67
+ } = {}) {
68
+ const cfg = { ...PANEL_IDLE_DEFAULTS };
69
+ if (Number.isFinite(idleSec) && idleSec > 0) cfg.idleMs = Math.floor(idleSec) * 1000;
70
+
71
+ let lastActivityAt = now();
72
+ let sawClient = false;
73
+ let released = false;
74
+ let timer = null;
75
+
76
+ const noteActivity = () => {
77
+ lastActivityAt = now();
78
+ sawClient = true;
79
+ };
80
+
81
+ const snapshot = async () => {
82
+ let waitingTopics = 0;
83
+ try {
84
+ waitingTopics = await getWaiting();
85
+ } catch {
86
+ // 读盘失败按 0 放行:这是瞬时错误,下一轮会重试;若反过来「读不到就永不释放」,
87
+ // 一次偶发失败会让进程永远留驻。store 真的坏了时面板本身也早不可用了。
88
+ waitingTopics = 0;
89
+ }
90
+ return {
91
+ autoExit: true,
92
+ sawClient,
93
+ idleForMs: now() - lastActivityAt,
94
+ liveClients: Math.max(0, Number(getLive()) || 0),
95
+ waitingTopics,
96
+ };
97
+ };
98
+
99
+ const stop = () => {
100
+ if (timer) {
101
+ clearInterval(timer);
102
+ timer = null;
103
+ }
104
+ };
105
+
106
+ const release = async () => {
107
+ if (released) return;
108
+ released = true;
109
+ stop();
110
+ try {
111
+ // 先掐掉残留连接:keep-alive 空闲套接字与长连接会让 server.close() 一直等下去。
112
+ server.closeAllConnections?.();
113
+ } catch {
114
+ /* 老 Node 无此 API:退回 close() 即可 */
115
+ }
116
+ await new Promise((res) => {
117
+ try {
118
+ server.close(() => res());
119
+ } catch {
120
+ res();
121
+ }
122
+ });
123
+ if (typeof onExit === 'function') onExit();
124
+ else process.exit(0);
125
+ };
126
+
127
+ const tick = async () => {
128
+ if (released) return false;
129
+ const snap = await snapshot();
130
+ if (!shouldAutoExit(snap, cfg)) return false;
131
+ log(
132
+ `面板已无连接、也没有等待中的主题(静默 ${Math.round(snap.idleForMs / 1000)}s)→ ` +
133
+ `自动释放 serve,端口 ${portOf(server)}`
134
+ );
135
+ await release();
136
+ return true;
137
+ };
138
+
139
+ timer = setInterval(() => {
140
+ tick().catch(() => {});
141
+ }, tickMs);
142
+ // 不靠这个定时器维持进程存活:进程该由 http server 撑着;server 关掉后自然结束。
143
+ timer.unref?.();
144
+
145
+ return { noteActivity, snapshot, tick, stop, released: () => released };
146
+ }
147
+
148
+ function portOf(server) {
149
+ try {
150
+ const a = server.address();
151
+ return a && typeof a === 'object' ? a.port : String(a ?? '');
152
+ } catch {
153
+ return '';
154
+ }
155
+ }