nx-kv 0.6.0 → 0.8.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.
@@ -0,0 +1,149 @@
1
+ # kv-waittask-workflow — 阻塞等待用户回填任务的完整 SOP
2
+
3
+ > 归属 [[kv-waittask]]。当你要在一个对话里**不结束本轮**、靠一个阻塞工具
4
+ > 调用等浏览器里继续回填任务时,按本 SOP 走。
5
+
6
+ ## 一、端到端时序
7
+
8
+ ```
9
+ 1. agent 正常干活(分析、读写文件、调用其它工具)。
10
+ 2. 当前任务做完、提交阶段结果,需要下一条输入时:
11
+ → 把「nx-kv localtask wait --summary "<当前现状总结>" --json」
12
+ 作为本轮【最后一个动作】调用;工具调用超时设为 300000 ms。
13
+ 3. wait 的行为:
14
+ a. 若本目录主题队列已有待领取任务(含 kv-submit 委托排队的)→ 立即取走、
15
+ 不开窗、不阻塞(queued:true);
16
+ b. 否则在本机临时起一个 http 服务器(127.0.0.1,随机端口),
17
+ 自动打开浏览器弹窗,然后阻塞。
18
+ 4. 用户在弹窗/面板看清现状,回填新任务 → 提交(Enter 或按钮)。
19
+ 5. wait 解除阻塞,stdout(--json) 返回 {status:"ok", task},进程退出。
20
+ 6. agent 从工具结果读 task.text,继续干活、提交结果;做完回到第 2 步——循环。
21
+ 7. 若阻塞时限(默认 180 秒)内没有回填:wait 返回 {status:"timeout"}(exit 0),
22
+ agent 不再等待,收尾并结束本轮。
23
+ ```
24
+
25
+ 关键点:**第 2 步之后、工具结果回来之前,agent 不产生任何其它动作。** 用户的
26
+ 回填正是通过这个工具结果进入对话的,等于用工具调用替代了在原始输入框打字,
27
+ agent 这一轮不会因为「等输入」而提前结束。
28
+
29
+ ## 二、现状总结怎么写
30
+
31
+ wait 必带一句当前现状总结(≤2000 字,连续空白会折叠为单空格):
32
+
33
+ - 它**只是状态展示**:显示在弹窗/面板顶部,并作为「本轮完成总结」记录在被取走
34
+ 的任务上(`task.roundSummary`),供用户和后续追溯。
35
+ - **它不参与队列分桶**:领取顺序只由本目录队列的先后决定,与措辞无关——同一
36
+ 目录的多次 wait(即便现状措辞不同)共用同一个任务队列。所以不必刻意保持
37
+ 逐字一致,每次如实写当前现状即可。
38
+ - 总结要让用户**不读整段对话也能快速了解现状**,建议包含三要素:
39
+ 1. 已经做到哪一步 / 产出了什么;
40
+ 2. 当前卡在哪、有哪些可选项;
41
+ 3. 具体等用户决定或补充什么。
42
+ - 一句话为宜。示例:
43
+ - `已完成登录,等待选择工作空间`
44
+ - `已生成 3 个方案(A/B/C),等待你选一个再细化`
45
+ - `面板已能提交,但删除还没接,等待确认是否现在补`
46
+
47
+ ## 三、队列语义(按目录划分、可提前排队)
48
+
49
+ - **工作目录即主题**:wait 不传 `--cwd` 时按进程当前目录的**目录名**定位主题
50
+ (Windows 折叠大小写,同名目录共用队列);`add` 可用 `--cwd` 给任意目录的
51
+ 队列排任务。不同目录 → 不同主题 → 队列互不串。
52
+ - 用户在弹窗、常驻面板「本地任务」页、或别人用 `localtask add`(kv-submit)
53
+ 提交的回填,都会以 `pending` 进入对应目录的队列。
54
+ - wait 总是取走该队列**最早的一条 pending**,标记为已领取(consumed)后返回:
55
+ - 队列非空 → wait 立刻返回(`queued:true`),连弹窗都不打开;
56
+ - 队列为空 → 才开窗阻塞(`delivered:true`)。
57
+ - 已领取的任务保留为历史(状态「已领取」),不会被二次取走。
58
+
59
+ 这让「趁 agent 还在忙,先把后面几条排好」成为可能:agent 每次 wait 都能立刻
60
+ 拿到下一条,不打断节奏。
61
+
62
+ ## 四、返回形状与退出码
63
+
64
+ 成功(exit 0):
65
+
66
+ ```json
67
+ {
68
+ "status": "ok",
69
+ "cwd": "D:\\code\\a_js\\proj\\demo",
70
+ "summary": "已完成登录,等待选择工作空间",
71
+ "task": { "id": 3, "text": "选择工作空间 A", "status": "consumed",
72
+ "createdAt": "2026-10-11 10:00:00", "consumedAt": "2026-10-11 10:00:04",
73
+ "roundSummary": "已完成登录,等待选择工作空间" },
74
+ "delivered": true,
75
+ "queued": false
76
+ }
77
+ ```
78
+
79
+ - `delivered:true` = 实时回填;`queued:true` = 取到队列里排队的任务(二者互斥)。
80
+ - **任务内容永远读 `task.text`**,多行换行原样保留。
81
+
82
+ 超时(exit 0,业务结果而非错误):
83
+
84
+ ```json
85
+ { "status": "timeout", "cwd": "D:\\code\\a_js\\proj\\demo",
86
+ "summary": "已完成登录,等待选择工作空间", "timeoutMs": 180000 }
87
+ ```
88
+
89
+ - 超时**不是失败**:退出码 0,按「用户没继续」收尾,不要按错误处理。
90
+ - 阻塞时限:默认用页面配置(推荐 180 秒,最大 600 秒),可用 `--timeout <秒>`
91
+ 覆盖(超上限自动截断)。
92
+ - 参数非法(如缺 `--summary`、缺 `--id`)才是错误(exit 1,文本带「用法」),
93
+ 按宿主失败流程处理。
94
+
95
+ ## 五、弹窗任务表怎么用(可转述给用户)
96
+
97
+ - 顶部「当前现状」直接显示 agent 传入的现状总结,先看这句再决定回填什么。
98
+ - 大输入框:回填下一条任务;**Enter 提交,Shift+Enter 换行**。
99
+ - 状态栏倒计时显示还会等多久;归零未回填则本轮自动结束。
100
+ - 下方是该主题的任务表,状态分「待领取 / 已领取」,可删除。
101
+ - 提交后显示「已回填给 agent,正在返回」即说明 wait 已取走、窗口可关闭;agent
102
+ 做完会再开下一个弹窗。
103
+
104
+ ## 六、多主题并存
105
+
106
+ - 每个工作目录一个主题;常驻面板「本地任务」页把所有主题并排展示,每张卡显示
107
+ 目录、当前现状、等待状态/倒计时、待领取数。
108
+ - 用户在任意主题卡上都能**分别回填**,也可趁 agent 在忙给几个目录提前排队。
109
+ - agent 在哪个目录跑 wait(或 `--cwd` 指哪个目录),就取哪个目录队列的任务,
110
+ 互不串。
111
+
112
+ ## 七、删除主题与批量删除
113
+
114
+ 主题(工作目录)可整体删除,不必逐条删任务:
115
+
116
+ - **单个删除**:
117
+ - 面板:主题卡片右下角「删除主题」;
118
+ - CLI:`nx-kv localtask topic remove --cwd "<工作目录>"`(或 `--key <key>`)。
119
+ - **批量删除**:
120
+ - 面板:勾选卡片左上角(或总览标题处「全选」)→「批量删除 (N)」;
121
+ - CLI:`nx-kv localtask topic remove-many --keys "<key,key>"`,也可
122
+ `--cwds "<dir,dir>"`,或 `--all` 清空。
123
+ - **安全保护**:等待中的主题默认不删(删了阻塞的 agent 只能等到超时);界面会
124
+ 二次确认,命令需加 `--force` 才连等待中一起删,agent 随后超时收尾。
125
+ - **返回**:单个 `{status:"removed",topic}`;批量 `{count,removed,skipped}`,
126
+ skipped 列出被跳过的「等待中 / 不存在」主题。
127
+
128
+ ## 八、常见错误与避坑
129
+
130
+ | 错误做法 | 后果 | 正确做法 |
131
+ | --- | --- | --- |
132
+ | wait 后又安排别的工具/推理 | wait 阻塞,后续动作被卡住、顺序全乱 | wait 严格放在最后一步,之后只处理返回 |
133
+ | 工具超时没设 / 设成 120000 | CLI 默认阻塞 180 秒,宿主会在阻塞中途杀掉调用 | 工具超时设 `300000` ms;用更长的 `--timeout` 时同步放大 |
134
+ | wait 不带 / 空着 --summary | 无法展示现状,命令报错退出 | 每次 wait 都写一句现状总结 |
135
+ | 刻意维持现状措辞逐字一致 | 白费功夫——现状只是展示,不影响领取 | 每次如实写当前现状即可 |
136
+ | 在错误的目录调 wait(没传 --cwd) | 取到别的目录队列的任务,或开出一个没人看的弹窗 | 确认进程 cwd 或显式 `--cwd "<目标目录>"` |
137
+ | 把 timeout 当报错、反复重试 | 用户想停却被反复弹窗 | timeout 即收尾结束本轮 |
138
+ | 读 `task.id` 当任务内容 | id 只是编号,内容在 `task.text` | 一律读 `task.text` |
139
+ | 同时开多个 wait(同一目录主题) | 只有一个收到回填,另一个等到超时 | 同一目录同一时刻只调一个 wait |
140
+ | 没用 --json | 输出人读文本,宿主难以可靠解析 | agent 调用一律加 `--json` |
141
+
142
+ ## 九、与常驻面板配合 / 排障
143
+
144
+ - 已 `nx-kv serve` 打开面板时,直接在「本地任务」页点选主题回填;正在阻塞的
145
+ wait 通过轮询(约每 0.5 秒)取走,不依赖弹窗本身。
146
+ - 弹窗没自动打开时:终端输出里有本次地址(`http://127.0.0.1:<端口>/`),手动
147
+ 复制到浏览器打开;该窗口只在本次 wait 期间有效。
148
+ - wait 结束(拿到任务或超时)后弹窗服务器随即关闭,窗口里再提交会失败——下一
149
+ 条等 agent 下一次 wait 打开新窗口再给,或直接在常驻面板回填。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.6.0",
4
- "description": "nx-kv — KV 清单管理中枢 + 对话内实时任务接力:serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 领取任务与阻塞接收用户输入",
3
+ "version": "0.8.0",
4
+ "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nx-kv": "bin/cli.mjs"
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ export * as config from './core/config.js';
4
4
  export * as kvapi from './core/kvapi.js';
5
5
  export * as errors from './core/errors.js';
6
6
  export * as todo from './modules/todo/service.js';
7
- export * as sessiontask from './modules/sessiontask/service.js';
7
+ export * as localtask from './modules/localtask/service.js';
8
8
 
9
9
  export { ACTIONS, MODULES } from './runtime/registry.js';
10
10
  export { startServer } from './runtime/server.js';
@@ -1,14 +1,16 @@
1
- // 实时任务输入模块:在一个对话里通过工具调用向 agent 实时回填任务,替代原始输入框。
1
+ // 本地任务(localtask)模块:本机按「工作目录(cwd)」划分的任务队列——提交(add)、
2
+ // 领取(wait)、查看/编辑/删除/调序(list/get/update/remove/reorder)与主题管理。
3
+ // 阻塞等待只是当前的一个子功能:agent 在一个对话里需要用户继续给任务时,不必结束
4
+ // 本轮、等用户去原始输入框打字,而是把
5
+ // `localtask wait --cwd "<工作目录>" --summary "<当前现状>"` 作为本轮**最后一步**调用:
6
+ // 命令阻塞、自动打开浏览器弹窗,用户回填后解除阻塞、把新任务作为结果返回,agent 接着干。
2
7
  //
3
- // 声明了 `resource: 'sessiontask'`,故 CRUD 五操作齐备且两端可调用
4
- // (tests/unit/registry.test.mjs 据此断言)。核心动作是纯 CLI 的
5
- // `sessiontask wait --cwd "<工作目录>" --summary "<当前现状>"`——agent 把它放在本轮
6
- // **最后一步**调用:阻塞、自动打开浏览器弹窗,用户回填后解除阻塞,把新任务返回给
7
- // agent。工作目录即主题、主题拥有任务队列:同目录共用队列、不同目录互不串,任务可
8
- // 在主题队列里堆积。
8
+ // 声明了 `resource: 'localtask'`,故 CRUD 五操作齐备且两端可调用
9
+ // (tests/unit/registry.test.mjs 据此断言)。工作目录即主题、主题拥有任务队列:
10
+ // 同目录共用队列、不同目录互不串,任务可在主题队列里堆积。
9
11
  //
10
- // 与 todo 的区别:todo 管 KV 后端上的清单(需登录);sessiontask 是**本机**
11
- // 实时人机接力,数据存本地文件,不依赖后端登录态。
12
+ // 与 todo 的区别:todo 管 KV 后端上的清单(需登录);localtask 是**本机**
13
+ // 任务队列,数据存本地文件,不依赖后端登录态。
12
14
  import * as service from './service.js';
13
15
 
14
16
  const CWD = { type: 'string', hint: '工作目录(作为主题;默认当前目录)' };
@@ -60,38 +62,53 @@ function renderWait(r) {
60
62
  );
61
63
  }
62
64
 
65
+ // 批量删除的人读输出:删除清单 + 跳过清单(等待中/不存在)
66
+ function renderRemoveMany(d) {
67
+ const lines = [d.all ? `清空:删除 ${d.count} 个主题` : `已删除 ${d.count} 个主题`];
68
+ for (const r of d.removed) lines.push(` - ${r.name}${r.cwd ? ` ${r.cwd}` : ''}`);
69
+ if (d.skipped.length) {
70
+ lines.push(`跳过 ${d.skipped.length} 个:`);
71
+ for (const s of d.skipped) {
72
+ const why = s.reason === 'waiting' ? '等待中' : '不存在';
73
+ lines.push(` - [${why}] ${s.name || s.key}`);
74
+ }
75
+ }
76
+ if (!d.removed.length && !d.skipped.length) lines.push('(没有匹配的主题)');
77
+ return lines.join('\n');
78
+ }
79
+
63
80
  export default {
64
- id: 'sessiontask',
65
- title: '实时任务(= Web「实时任务」页)',
81
+ id: 'localtask',
82
+ title: '本地任务(= Web「本地任务」页)',
66
83
  order: 20,
67
84
  view: () => import('./view.jsx'),
68
85
 
69
86
  // CRUD 资源声明:主题任务的增删查改齐备,CLI 与面板都能调用
70
- resource: 'sessiontask',
87
+ resource: 'localtask',
71
88
 
72
89
  actions: [
73
90
  // ─── CRUD 五操作 ───────────────────────────────────────────
74
91
  {
75
- id: 'sessiontask.list',
76
- cli: ['sessiontask', 'list'],
77
- http: ['GET', '/api/sessiontasks'],
92
+ id: 'localtask.list',
93
+ cli: ['localtask', 'list'],
94
+ http: ['GET', '/api/localtasks'],
78
95
  summary: '总览全部主题(工作目录、现状、是否等待中、待领取数)',
79
96
  run: () => service.listTasks(),
80
97
  render: renderOverview,
81
98
  },
82
99
  {
83
- id: 'sessiontask.get',
84
- cli: ['sessiontask', 'get'],
85
- http: ['GET', '/api/sessiontasks/item'],
100
+ id: 'localtask.get',
101
+ cli: ['localtask', 'get'],
102
+ http: ['GET', '/api/localtasks/item'],
86
103
  summary: '查看单条任务(按 --id,定位到 --cwd 主题)',
87
104
  flags: { id: { type: 'number', required: true, hint: '任务编号' }, cwd: CWD },
88
105
  run: (ctx) => service.getTask({ id: ctx.id, cwd: ctx.cwd }),
89
106
  render: renderTask,
90
107
  },
91
108
  {
92
- id: 'sessiontask.add',
93
- cli: ['sessiontask', 'add'],
94
- http: ['POST', '/api/sessiontasks'],
109
+ id: 'localtask.add',
110
+ cli: ['localtask', 'add'],
111
+ http: ['POST', '/api/localtasks'],
95
112
  summary: '向某主题(工作目录)的队列回填一条任务(有 wait 在等则即时投递,否则排队)',
96
113
  args: ['text'],
97
114
  flags: { cwd: CWD, summary: { type: 'string', hint: '顺带刷新该主题的当前现状(可选)' } },
@@ -99,9 +116,9 @@ export default {
99
116
  render: renderAdd,
100
117
  },
101
118
  {
102
- id: 'sessiontask.update',
103
- cli: ['sessiontask', 'update'],
104
- http: ['PATCH', '/api/sessiontasks/item'],
119
+ id: 'localtask.update',
120
+ cli: ['localtask', 'update'],
121
+ http: ['PATCH', '/api/localtasks/item'],
105
122
  summary: '编辑任务内容(按 --id,定位到 --cwd 主题)',
106
123
  flags: {
107
124
  id: { type: 'number', required: true, hint: '任务编号' },
@@ -112,19 +129,50 @@ export default {
112
129
  render: (d) => `已更新主题「${d.name}」#${d.task.id}: ${d.task.text}`,
113
130
  },
114
131
  {
115
- id: 'sessiontask.remove',
116
- cli: ['sessiontask', 'remove'],
117
- http: ['DELETE', '/api/sessiontasks/item'],
132
+ id: 'localtask.remove',
133
+ cli: ['localtask', 'remove'],
134
+ http: ['DELETE', '/api/localtasks/item'],
118
135
  summary: '删除一条任务(按 --id,定位到 --cwd 主题)',
119
136
  flags: { id: { type: 'number', required: true, hint: '任务编号' }, cwd: CWD },
120
137
  run: (ctx) => service.removeTask({ id: ctx.id, cwd: ctx.cwd }),
121
138
  render: (d) => `已删除主题「${d.name}」#${d.removed.id}: ${d.removed.text}`,
122
139
  },
123
140
 
141
+ // ─── 主题删除(单个 / 批量)──────────────────────────────
142
+ {
143
+ id: 'localtask.topic.remove',
144
+ cli: ['localtask', 'topic', 'remove'],
145
+ http: ['DELETE', '/api/localtasks/topic'],
146
+ summary: '删除整个主题(含全部任务;按 --cwd 或 --key,等待中需 --force)',
147
+ flags: {
148
+ cwd: { type: 'string', hint: '工作目录(与 --key 二选一)' },
149
+ key: { type: 'string', hint: '主题 key(与 --cwd 二选一)' },
150
+ force: { type: 'boolean', hint: '连等待中的主题也删(agent 将超时收尾)' },
151
+ },
152
+ run: (ctx) => service.removeTopic({ cwd: ctx.cwd, key: ctx.key, force: ctx.force }),
153
+ render: (d) =>
154
+ `已删除主题「${d.name}」(待领取/共 ${d.topic.pending}/${d.topic.total} 条任务一并删除)`,
155
+ },
156
+ {
157
+ id: 'localtask.topic.removeMany',
158
+ cli: ['localtask', 'topic', 'remove-many'],
159
+ http: ['POST', '/api/localtasks/topics/remove'],
160
+ summary: '批量删除主题(--keys/--cwds 逗号分隔,或 --all;等待中默认跳过,--force 连等待中删)',
161
+ flags: {
162
+ keys: { type: 'array', hint: '主题 key,逗号分隔' },
163
+ cwds: { type: 'array', hint: '工作目录,逗号分隔' },
164
+ all: { type: 'boolean', hint: '清空全部主题(等待中仍跳过,除非 --force)' },
165
+ force: { type: 'boolean', hint: '连等待中的主题一起删' },
166
+ },
167
+ run: (ctx) =>
168
+ service.removeTopics({ keys: ctx.keys, cwds: ctx.cwds, all: ctx.all, force: ctx.force }),
169
+ render: renderRemoveMany,
170
+ },
171
+
124
172
  {
125
- id: 'sessiontask.config',
126
- cli: ['sessiontask', 'config'],
127
- http: ['PATCH', '/api/sessiontasks/setting'],
173
+ id: 'localtask.config',
174
+ cli: ['localtask', 'config'],
175
+ http: ['PATCH', '/api/localtasks/setting'],
128
176
  summary: '配置等待超时(秒;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)',
129
177
  flags: { timeout: { type: 'number', required: true, hint: '超时秒数(1-600)' } },
130
178
  run: (ctx) => service.updateSettings({ timeoutSec: ctx.timeout }),
@@ -132,9 +180,9 @@ export default {
132
180
  },
133
181
 
134
182
  {
135
- id: 'sessiontask.prune',
136
- cli: ['sessiontask', 'prune'],
137
- http: ['POST', '/api/sessiontasks/prune'],
183
+ id: 'localtask.prune',
184
+ cli: ['localtask', 'prune'],
185
+ http: ['POST', '/api/localtasks/prune'],
138
186
  summary: '清理旧脏数据(旧模型无目录主题、空主题;等待中一律保留)',
139
187
  flags: {
140
188
  'all-finished': { type: 'boolean', hint: '连同已全部领取、无待领的主题一起清' },
@@ -149,9 +197,9 @@ export default {
149
197
  },
150
198
 
151
199
  {
152
- id: 'sessiontask.reorder',
153
- cli: ['sessiontask', 'reorder'],
154
- http: ['PATCH', '/api/sessiontasks/reorder'],
200
+ id: 'localtask.reorder',
201
+ cli: ['localtask', 'reorder'],
202
+ http: ['PATCH', '/api/localtasks/reorder'],
155
203
  summary: '调整待领取任务优先级(--id <编号> 移到 --before-id <编号> 前或 --after-id <编号> 后)',
156
204
  flags: {
157
205
  id: { type: 'number', required: true, hint: '要移动的任务编号' },
@@ -169,8 +217,8 @@ export default {
169
217
 
170
218
  // ─── 阻塞等待(纯 CLI,agent 本轮最后一步)────────────────
171
219
  {
172
- id: 'sessiontask.wait',
173
- cli: ['sessiontask', 'wait'],
220
+ id: 'localtask.wait',
221
+ cli: ['localtask', 'wait'],
174
222
  http: null,
175
223
  summary: '针对某主题(按 cwd 目录名识别)阻塞等待用户回填(默认 3 分钟,最大 10 分钟)',
176
224
  flags: {
@@ -12,7 +12,7 @@ export function renderPopupPage({ key, cwd, summary, timeoutMs }) {
12
12
  <head>
13
13
  <meta charset="utf-8" />
14
14
  <meta name="viewport" content="width=device-width, initial-scale=1" />
15
- <title>nx-kv · 实时任务</title>
15
+ <title>nx-kv · 本地任务</title>
16
16
  <style>
17
17
  :root {
18
18
  --ink: #14161a;
@@ -109,7 +109,7 @@ export function renderPopupPage({ key, cwd, summary, timeoutMs }) {
109
109
  <body>
110
110
  <div class="wrap">
111
111
  <div class="top">
112
- <div class="brand">nx-kv<span class="sub">实时任务输入</span></div>
112
+ <div class="brand">nx-kv<span class="sub">本地任务</span></div>
113
113
  <div class="meta"><span class="pill">回填后立即返回 agent</span></div>
114
114
  </div>
115
115
 
@@ -217,7 +217,7 @@ export function renderPopupPage({ key, cwd, summary, timeoutMs }) {
217
217
 
218
218
  function refresh() {
219
219
  if (delivered) return;
220
- fetch('/api/sessiontasks', { cache: 'no-store' })
220
+ fetch('/api/localtasks', { cache: 'no-store' })
221
221
  .then(function (r) { return r.json(); })
222
222
  .then(function (j) {
223
223
  if (!j.ok) return;
@@ -234,7 +234,7 @@ export function renderPopupPage({ key, cwd, summary, timeoutMs }) {
234
234
  var input = el('input');
235
235
  var text = input.value.trim();
236
236
  if (!text) { input.focus(); return; }
237
- fetch('/api/sessiontasks', {
237
+ fetch('/api/localtasks', {
238
238
  method: 'POST',
239
239
  headers: { 'content-type': 'application/json' },
240
240
  body: JSON.stringify({ text: text })
@@ -266,7 +266,7 @@ export function renderPopupPage({ key, cwd, summary, timeoutMs }) {
266
266
  el('list').addEventListener('click', function (e) {
267
267
  var b = e.target;
268
268
  if (b && b.dataset && b.dataset.id) {
269
- fetch('/api/sessiontasks/item', {
269
+ fetch('/api/localtasks/item', {
270
270
  method: 'DELETE',
271
271
  headers: { 'content-type': 'application/json' },
272
272
  body: JSON.stringify({ id: Number(b.dataset.id) })
@@ -1,8 +1,8 @@
1
- // 实时任务输入 service —— 按「工作目录(cwd)」划分的主题(topic)+ 主题任务队列 +
2
- // 阻塞等待 + 内嵌弹窗。
1
+ // 本地任务(localtask)service —— 按「工作目录(cwd)」划分的主题(topic)+ 主题任务队列 +
2
+ // 领取(含阻塞等待子功能)+ 内嵌弹窗。
3
3
  //
4
4
  // 解决的问题:agent 在一个对话里需要用户继续给任务时,不必结束本轮、等用户去
5
- // 原始输入框打字,而是把 `nx-kv sessiontask wait --cwd "<工作目录>" --summary "<当前现状>"`
5
+ // 原始输入框打字,而是把 `nx-kv localtask wait --cwd "<工作目录>" --summary "<当前现状>"`
6
6
  // 当作 **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板回填,
7
7
  // 命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
8
8
  //
@@ -16,7 +16,7 @@
16
16
  // http 服务器直出,未登录也能用。
17
17
  // 4. **排队语义。** 用户可提前回填:主题已有 pending 时 wait 立即取走、不开窗;
18
18
  // 没有 pending 才开窗阻塞。
19
- // 5. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
19
+ // 5. **超时是业务结果不是错误。** 默认阻塞 3 分钟(页面可调,上限 10 分钟);超时返回 {status:'timeout'}、
20
20
  // exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
21
21
  //
22
22
  // 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
@@ -37,10 +37,21 @@ 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
- // ─── 存储位置:可环境变量覆盖(测试隔离) ───────────────────────────
40
+ // ─── 存储位置:可环境变量覆盖(测试隔离);旧名/旧文件自动兼容 ──────────
41
41
 
42
42
  export function storePath() {
43
- return process.env.NX_KV_SESSIONTASK_STORE || join(APP_DIR, 'sessiontasks.json');
43
+ return (
44
+ process.env.NX_KV_LOCALTASK_STORE ||
45
+ process.env.NX_KV_SESSIONTASK_STORE || // 改名前的环境变量,兼容既有脚本
46
+ join(APP_DIR, 'localtasks.json')
47
+ );
48
+ }
49
+
50
+ // 改名前的存储文件。显式覆盖(任一环境变量)时不迁移;新文件缺失时读它,
51
+ // 首次保存即落到新文件(旧文件保留不删,数据零丢失)。
52
+ function legacyStorePath() {
53
+ if (process.env.NX_KV_LOCALTASK_STORE || process.env.NX_KV_SESSIONTASK_STORE) return null;
54
+ return join(APP_DIR, 'sessiontasks.json');
44
55
  }
45
56
 
46
57
  // ─── 工作目录 → 主题 ───────────────────────────────────────────────
@@ -141,7 +152,15 @@ async function loadStore() {
141
152
  let topics = null;
142
153
  let settings = null;
143
154
  try {
144
- const raw = await fsp.readFile(storePath(), 'utf8');
155
+ let raw;
156
+ try {
157
+ raw = await fsp.readFile(storePath(), 'utf8');
158
+ } catch (err) {
159
+ // 新文件不存在 → 尝试改名前的旧文件(一次性迁移读取;再缺失才落到空 store)
160
+ const legacy = err && err.code === 'ENOENT' ? legacyStorePath() : null;
161
+ if (!legacy) throw err;
162
+ raw = await fsp.readFile(legacy, 'utf8');
163
+ }
145
164
  const data = JSON.parse(raw);
146
165
  if (data && typeof data === 'object') {
147
166
  if (data.topics && typeof data.topics === 'object') {
@@ -274,6 +293,18 @@ async function setWaiting(key, cwd, summary, waiting, deadline) {
274
293
  await saveStore(store);
275
294
  }
276
295
 
296
+ // wait 结束复位:**仅当主题仍存在**时清等待态,绝不重建——主题可能已被 force 删除,
297
+ // 重建会让「删除主题」失效(曾在超时清理路径上把删掉的主题又建回来)。
298
+ async function clearWaiting(key) {
299
+ const store = await loadStore();
300
+ const t = store.topics[key];
301
+ if (!t) return;
302
+ t.waiting = false;
303
+ t.waitingUntil = '';
304
+ t.updatedAt = stampNow();
305
+ await saveStore(store);
306
+ }
307
+
277
308
  // ─── CRUD ───────────────────────────────────────────────────────────
278
309
 
279
310
  function settingsView(s) {
@@ -430,6 +461,76 @@ export async function removeTask({ cwd: rawCwd, id } = {}) {
430
461
  return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
431
462
  }
432
463
 
464
+ // ─── 主题删除(单个 / 批量) ───────────────────────────────────────
465
+
466
+ // 定位主题 key:显式 --key 优先,否则按工作目录(目录名)推导
467
+ function resolveTopicKey({ key: rawKey, cwd: rawCwd }) {
468
+ if (rawKey && String(rawKey).trim()) return String(rawKey).trim();
469
+ return normalizeCwd(rawCwd).key;
470
+ }
471
+
472
+ // 删除整个主题(连同其全部任务)。默认拒绝删除「等待中」主题——那会让正阻塞的
473
+ // agent 只能等到超时;force=true 才强删(agent 随后超时收尾)。
474
+ export async function removeTopic({ cwd: rawCwd, key: rawKey, force = false } = {}) {
475
+ if (!rawKey && !rawCwd) {
476
+ throw badInput('需要 --cwd "<工作目录>" 或 --key "<主题key>"');
477
+ }
478
+ const key = resolveTopicKey({ key: rawKey, cwd: rawCwd });
479
+ const store = await loadStore();
480
+ const t = store.topics[key];
481
+ if (!t) {
482
+ const { notFound } = await import('../../core/errors.js');
483
+ throw notFound(`主题不存在(key ${key})`);
484
+ }
485
+ if (t.waiting && !force) {
486
+ throw badInput('主题正在等待中,不能删除(确认要删请加 --force,agent 将超时收尾)');
487
+ }
488
+ const view = topicView(t);
489
+ delete store.topics[key];
490
+ await saveStore(store);
491
+ return { status: 'removed', key, name: t.name, cwd: t.cwd, topic: view };
492
+ }
493
+
494
+ // 批量删除主题:keys / cwds(数组,可并用),或 all=true 清空全部。
495
+ // 默认把「等待中」「不存在」计入 skipped 而不删;force=true 连等待中一起删。
496
+ export async function removeTopics({ keys, cwds, all = false, force = false } = {}) {
497
+ const hasList = (a) => Array.isArray(a) && a.some((x) => String(x).trim());
498
+ if (!all && !hasList(keys) && !hasList(cwds)) {
499
+ throw badInput('需要 --keys "<key,key>" 或 --cwds "<目录,目录>",或 --all');
500
+ }
501
+
502
+ const store = await loadStore();
503
+ let targets;
504
+ if (all) {
505
+ targets = Object.keys(store.topics);
506
+ } else {
507
+ const list = [];
508
+ if (Array.isArray(keys)) list.push(...keys.map((x) => String(x).trim()).filter(Boolean));
509
+ if (Array.isArray(cwds)) {
510
+ for (const c of cwds) if (String(c).trim()) list.push(normalizeCwd(c).key);
511
+ }
512
+ targets = [...new Set(list)];
513
+ }
514
+
515
+ const removed = [];
516
+ const skipped = [];
517
+ for (const key of targets) {
518
+ const t = store.topics[key];
519
+ if (!t) {
520
+ skipped.push({ key, reason: 'not_found' });
521
+ continue;
522
+ }
523
+ if (t.waiting && !force) {
524
+ skipped.push({ key, name: t.name, cwd: t.cwd, reason: 'waiting' });
525
+ continue;
526
+ }
527
+ removed.push({ key, name: t.name, cwd: t.cwd });
528
+ delete store.topics[key];
529
+ }
530
+ if (removed.length) await saveStore(store);
531
+ return { status: 'ok', all: !!all, count: removed.length, removed, skipped };
532
+ }
533
+
433
534
  // 调整**待领取**任务的优先级:把 #id 移动到 #beforeId 之前或 #afterId 之后。
434
535
  // 领取顺序 = tasks 数组里 pending 子序列的先后(consumeFirstPending 取第一条 pending),
435
536
  // 故只在 pending 之间换位;已领取(历史)任务固定、不可移动。
@@ -562,7 +663,7 @@ export async function waitForTask({
562
663
  clearInterval(poll);
563
664
  clearTimeout(timer);
564
665
  waiters.delete(key);
565
- await setWaiting(key, cwd, '', false);
666
+ await clearWaiting(key);
566
667
  await stopServer(server);
567
668
  }
568
669
  return result;
@@ -621,12 +722,12 @@ async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
621
722
  return;
622
723
  }
623
724
 
624
- if (url.pathname === '/api/sessiontasks' && method === 'GET') {
725
+ if (url.pathname === '/api/localtasks' && method === 'GET') {
625
726
  sendJson(res, 200, { ok: true, data: await listTasks() });
626
727
  return;
627
728
  }
628
729
 
629
- if (url.pathname === '/api/sessiontasks' && method === 'POST') {
730
+ if (url.pathname === '/api/localtasks' && method === 'POST') {
630
731
  if (!originAllowed(req)) {
631
732
  sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
632
733
  return;
@@ -636,7 +737,7 @@ async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
636
737
  return;
637
738
  }
638
739
 
639
- if (url.pathname === '/api/sessiontasks/item' && method === 'DELETE') {
740
+ if (url.pathname === '/api/localtasks/item' && method === 'DELETE') {
640
741
  if (!originAllowed(req)) {
641
742
  sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
642
743
  } else {