nx-kv 0.3.0 → 0.3.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.
@@ -1,19 +1,22 @@
1
- // 实时任务输入 service —— 按「现状总结」分散的等待点(slot)+ 阻塞等待 + 内嵌弹窗。
1
+ // 实时任务输入 service —— 按「工作目录(cwd)」划分的主题(topic)+ 主题任务队列 +
2
+ // 阻塞等待 + 内嵌弹窗。
2
3
  //
3
4
  // 解决的问题:agent 在一个对话里需要用户继续给任务时,不必结束本轮、等用户去
4
- // 原始输入框打字,而是把 `nx-kv sessiontask wait --summary "<当前现状总结>"` 当作
5
- // **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板针对
6
- // 该现状回填,命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
5
+ // 原始输入框打字,而是把 `nx-kv sessiontask wait --cwd "<工作目录>" --summary "<当前现状>"`
6
+ // 当作 **本轮最后一个动作**调用——命令阻塞、自动打开浏览器弹窗;用户在弹窗/面板回填,
7
+ // 命令解除阻塞、把新任务作为结果返回给 agent,agent 接着干。
7
8
  //
8
9
  // 关键设计:
9
- // 1. **现状总结即 key。** wait 必须带一句当前现状总结;相同总结 → 同一个等待点,
10
- // 不同总结 → 不同等待点,从而把答复「分散」到各现状下。面板总览每个等待点
11
- // 都显示现状,用户一眼看清、分别回填。
12
- // 2. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时
13
- // 起的 http 服务器直出,未登录也能用。
14
- // 3. **排队语义。** 用户可提前回填:等待点已有 pending 时 wait 立即取走、不开窗;
10
+ // 1. **工作目录即主题,主题拥有队列。** wait 传入工作目录(默认进程 cwd),归一化后
11
+ // 作为主题 key;同一目录的多次 wait(即便现状措辞不同)共用同一个任务队列,
12
+ // 不同目录 → 不同主题 → 队列互不串。任务可在一个主题队列里逐条堆积,agent 按序领取。
13
+ // 2. **现状总结只作状态展示。** --summary 是 agent 当前状态(人读),每次 wait 刷新,
14
+ // 不再参与队列分桶;用户据此一眼看清 agent 做到哪、卡在哪。
15
+ // 3. **纯本地协调,不依赖 KV 后端登录态。** 数据落本机文件,弹窗由本进程临时起的
16
+ // http 服务器直出,未登录也能用。
17
+ // 4. **排队语义。** 用户可提前回填:主题已有 pending 时 wait 立即取走、不开窗;
15
18
  // 没有 pending 才开窗阻塞。
16
- // 4. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
19
+ // 5. **超时是业务结果不是错误。** 默认阻塞 2 分钟;超时返回 {status:'timeout'}、
17
20
  // exit 0,agent 据此安静收尾结束本轮,绝不抛异常。
18
21
  //
19
22
  // 跨进程:弹窗与 wait 同进程时靠内存 waiter 即时唤醒;任务由别的进程写入(如常驻
@@ -21,15 +24,18 @@
21
24
  import http from 'node:http';
22
25
  import crypto from 'node:crypto';
23
26
  import fsp from 'node:fs/promises';
24
- import { dirname, join } from 'node:path';
27
+ import { basename, dirname, join, resolve } from 'node:path';
25
28
  import { APP_DIR } from '../../core/paths.js';
26
29
  import { badInput } from '../../core/errors.js';
27
30
  import { openBrowser } from '../../core/open.js';
28
31
  import { renderPopupPage } from './popup.js';
29
32
 
30
- export const DEFAULT_TIMEOUT_MS = 120_000; // 阻塞默认 2 分钟
33
+ export const DEFAULT_TIMEOUT_MS = 180_000; // 阻塞默认 3 分钟(推荐值)
31
34
  const POLL_MS = 500; // 跨进程兜底轮询间隔
32
- const MAX_SUMMARY = 200; // 现状总结长度上限
35
+ export const DEFAULT_TIMEOUT_SEC = 180; // 推荐阻塞时长(3 分钟)
36
+ export const MAX_TIMEOUT_SEC = 600; // 阻塞时长上限(10 分钟)
37
+ export const MAX_SUMMARY = 2000; // 现状(状态)长度上限(原 200,已放宽)
38
+ export const MAX_TEXT = 20_000; // 单条任务内容长度上限
33
39
 
34
40
  // ─── 存储位置:可环境变量覆盖(测试隔离) ───────────────────────────
35
41
 
@@ -37,19 +43,55 @@ export function storePath() {
37
43
  return process.env.NX_KV_SESSIONTASK_STORE || join(APP_DIR, 'sessiontasks.json');
38
44
  }
39
45
 
40
- // ─── 现状总结 → key ────────────────────────────────────────────────
46
+ // ─── 工作目录 → 主题 ───────────────────────────────────────────────
41
47
 
42
- // 归一化:折叠所有连续空白为单空格、去首尾;必填
43
- export function normalizeSummary(raw) {
48
+ /**
49
+ * 归一化工作目录并解析主题。
50
+ * @param {string} [raw] 目录路径;缺省/空 → 进程当前 cwd
51
+ * @returns {{cwd:string, key:string}}
52
+ */
53
+ export function normalizeCwd(raw) {
54
+ const trimmed = raw && String(raw).trim() ? String(raw).trim() : '';
55
+ let cwd;
56
+ try {
57
+ cwd = resolve(trimmed || process.cwd());
58
+ } catch {
59
+ cwd = process.cwd();
60
+ }
61
+ const name = basename(cwd);
62
+ return { cwd, name, key: topicKeyFor(name) };
63
+ }
64
+
65
+ // 主题以「目录名」标识:在名为 br_ct 的目录里执行 CLI → 自动领取 br_ct 主题队列的任务;
66
+ // 同名目录共用同一队列。Windows 折叠目录名大小写。
67
+ export function topicKeyFor(name) {
68
+ const fold = process.platform === 'win32' ? String(name).toLowerCase() : String(name);
69
+ return crypto.createHash('sha1').update(fold, 'utf8').digest('hex').slice(0, 16);
70
+ }
71
+
72
+ // ─── 现状(状态文本) ──────────────────────────────────────────────
73
+
74
+ // 归一化:折叠所有连续空白为单空格、去首尾。wait 必填;add 可选(仅刷新状态)。
75
+ export function normalizeSummary(raw, { optional = false } = {}) {
44
76
  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} 字)`);
77
+ if (!s) {
78
+ if (optional) return '';
79
+ throw badInput('缺少 --summary "<当前现状总结>"(agent 当前状态,展示给用户)');
80
+ }
81
+ if (s.length > MAX_SUMMARY) {
82
+ throw badInput(`--summary 过长(≤${MAX_SUMMARY} 字,当前 ${s.length} 字)`);
83
+ }
47
84
  return s;
48
85
  }
49
86
 
50
- // 相同现状 → 相同 key;不同现状 → 不同 key。展示仍用现状原文,key 仅作内部标识。
51
- export function keyForSummary(summary) {
52
- return crypto.createHash('sha1').update(summary, 'utf8').digest('hex').slice(0, 16);
87
+ // 任务正文:必填、限长
88
+ function normalizeText(raw) {
89
+ const body = String(raw ?? '').trim();
90
+ if (!body) throw badInput('任务内容不能为空');
91
+ if (body.length > MAX_TEXT) {
92
+ throw badInput(`任务内容过长(≤${MAX_TEXT} 字,当前 ${body.length} 字)`);
93
+ }
94
+ return body;
53
95
  }
54
96
 
55
97
  // ─── 时间 ───────────────────────────────────────────────────────────
@@ -63,39 +105,67 @@ export function stampNow(d = new Date()) {
63
105
  );
64
106
  }
65
107
 
66
- // ─── 存储读写(原子写 + 损坏容忍 + v1 迁移) ───────────────────────
108
+ // ─── 存储读写(原子写 + 损坏容忍 + v1/v2 迁移) ───────────────────
67
109
 
68
110
  function emptyStore() {
69
- return { version: 2, slots: {} };
111
+ return { version: 4, settings: { timeoutSec: DEFAULT_TIMEOUT_SEC }, topics: {} };
112
+ }
113
+
114
+ // 归一化超时秒数:非法/缺省 → 推荐值;超上限截断为 10 分钟
115
+ export function clampTimeoutSec(sec) {
116
+ let n = Number(sec);
117
+ if (!Number.isFinite(n) || n <= 0) n = DEFAULT_TIMEOUT_SEC;
118
+ n = Math.floor(n);
119
+ if (n > MAX_TIMEOUT_SEC) n = MAX_TIMEOUT_SEC;
120
+ return n;
121
+ }
122
+
123
+ // 旧的等待点(v2 slot / v1 session)转主题:旧 key 来自现状摘要、无 cwd 信息,
124
+ // 故保留原 key,用现状摘要截一段作主题名、cwd 留空。
125
+ function legacyTopic(key, b) {
126
+ const summary = (b && b.summary) || key;
127
+ return {
128
+ key,
129
+ cwd: '',
130
+ name: summary.length > 40 ? summary.slice(0, 40) + '…' : summary,
131
+ summary,
132
+ createdAt: (b && b.createdAt) || stampNow(),
133
+ updatedAt: (b && b.updatedAt) || stampNow(),
134
+ waiting: !!(b && b.waiting),
135
+ waitingUntil: (b && b.waitingUntil) || '',
136
+ tasks: b && Array.isArray(b.tasks) ? b.tasks : [],
137
+ };
70
138
  }
71
139
 
72
140
  async function loadStore() {
141
+ let topics = null;
142
+ let settings = null;
73
143
  try {
74
144
  const raw = await fsp.readFile(storePath(), 'utf8');
75
145
  const data = JSON.parse(raw);
76
146
  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 };
147
+ if (data.topics && typeof data.topics === 'object') {
148
+ topics = data.topics;
149
+ } else if (data.slots && typeof data.slots === 'object') {
150
+ // v2(slots 桶)→ topics
151
+ topics = {};
152
+ for (const [k, b] of Object.entries(data.slots)) topics[k] = legacyTopic(k, b);
153
+ } else if (data.sessions && typeof data.sessions === 'object') {
154
+ // v1(sessions 桶)→ topics
155
+ topics = {};
156
+ for (const [k, b] of Object.entries(data.sessions)) topics[k] = legacyTopic(k, b);
93
157
  }
158
+ if (data.settings && typeof data.settings === 'object') settings = data.settings;
94
159
  }
95
160
  } catch {
96
161
  /* 损坏/缺失 → 空 store */
97
162
  }
98
- return emptyStore();
163
+ if (!topics) return emptyStore();
164
+ return {
165
+ version: 4,
166
+ settings: { timeoutSec: clampTimeoutSec(settings && settings.timeoutSec) },
167
+ topics,
168
+ };
99
169
  }
100
170
 
101
171
  async function saveStore(data) {
@@ -106,23 +176,28 @@ async function saveStore(data) {
106
176
  await fsp.rename(tmp, p);
107
177
  }
108
178
 
109
- // 取(必要时新建)某个等待点
110
- function slotOf(store, key, summary) {
111
- let s = store.slots[key];
112
- if (!s || !Array.isArray(s.tasks)) {
113
- s = {
179
+ // 取(必要时新建)某个主题
180
+ function topicOf(store, key, cwd, name = '') {
181
+ const nm = name || (cwd ? basename(cwd) : '') || key;
182
+ let t = store.topics[key];
183
+ if (!t || !Array.isArray(t.tasks)) {
184
+ t = {
114
185
  key,
115
- summary,
186
+ cwd: cwd || '',
187
+ name: nm,
116
188
  createdAt: stampNow(),
117
189
  updatedAt: stampNow(),
118
190
  waiting: false,
119
191
  waitingUntil: '',
192
+ summary: '',
120
193
  tasks: [],
121
194
  };
122
- store.slots[key] = s;
195
+ store.topics[key] = t;
123
196
  }
124
- if (summary) s.summary = summary; // 同一 key 下刷新现状措辞
125
- return s;
197
+ if (cwd) t.cwd = cwd;
198
+ if (name) t.name = name;
199
+ else if (cwd) t.name = basename(cwd) || t.name;
200
+ return t;
126
201
  }
127
202
 
128
203
  function nextTaskId(tasks) {
@@ -144,16 +219,18 @@ function normalizeTask(t) {
144
219
  };
145
220
  }
146
221
 
147
- function slotView(s) {
148
- const tasks = s.tasks.map(normalizeTask);
222
+ function topicView(t) {
223
+ const tasks = t.tasks.map(normalizeTask);
149
224
  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,
225
+ key: t.key,
226
+ cwd: t.cwd || '',
227
+ name: t.name || '',
228
+ summary: t.summary || '',
229
+ waiting: !!t.waiting,
230
+ waitingUntil: t.waitingUntil || '',
231
+ createdAt: t.createdAt,
232
+ updatedAt: t.updatedAt,
233
+ pending: tasks.filter((x) => x.status !== 'consumed').length,
157
234
  total: tasks.length,
158
235
  tasks,
159
236
  };
@@ -169,149 +246,194 @@ function markConsumed(task) {
169
246
  return task;
170
247
  }
171
248
 
172
- // 取走该等待点第一条 pending;没有则返回 null
249
+ // 取走该主题第一条 pending;没有则返回 null
173
250
  export async function consumeFirstPending(key) {
174
251
  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');
252
+ const t = store.topics[key];
253
+ if (!t) return null;
254
+ const task = t.tasks.find((x) => x.status !== 'consumed');
178
255
  if (!task) return null;
179
256
  markConsumed(task);
180
- s.updatedAt = stampNow();
257
+ t.updatedAt = stampNow();
181
258
  await saveStore(store);
182
259
  return normalizeTask(task);
183
260
  }
184
261
 
185
- // 在 store 里把某等待点置为等待/结束
186
- async function setWaiting(key, summary, waiting, deadline) {
262
+ // 在 store 里把某主题置为等待/结束,并刷新现状
263
+ async function setWaiting(key, cwd, summary, waiting, deadline) {
187
264
  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();
265
+ const t = topicOf(store, key, cwd);
266
+ t.waiting = waiting;
267
+ t.waitingUntil = waiting && deadline ? stampNow(deadline) : '';
268
+ if (summary) t.summary = summary;
269
+ t.updatedAt = stampNow();
192
270
  await saveStore(store);
193
271
  }
194
272
 
195
273
  // ─── CRUD ───────────────────────────────────────────────────────────
196
274
 
197
- // 总览:返回全部等待点(面板据此分散展示与回填),按最近更新排序
275
+ function settingsView(s) {
276
+ return {
277
+ timeoutSec: clampTimeoutSec(s && s.timeoutSec),
278
+ recommendedSec: DEFAULT_TIMEOUT_SEC,
279
+ maxSec: MAX_TIMEOUT_SEC,
280
+ };
281
+ }
282
+
283
+ // 总览:返回全部主题(面板据此分组展示与回填)+ 超时设置,按最近更新排序
198
284
  export async function listTasks() {
199
285
  const store = await loadStore();
200
- const slots = Object.values(store.slots)
201
- .map(slotView)
286
+ const topics = Object.values(store.topics)
287
+ .map(topicView)
202
288
  .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
203
- return { slots, waiting: slots.filter((s) => s.waiting).length };
289
+ return { topics, waiting: topics.filter((t) => t.waiting).length, settings: settingsView(store.settings) };
204
290
  }
205
291
 
206
- export async function getTask({ summary: rawSummary, id } = {}) {
207
- const summary = normalizeSummary(rawSummary);
208
- const key = keyForSummary(summary);
292
+ export async function getSettings() {
293
+ const store = await loadStore();
294
+ return settingsView(store.settings);
295
+ }
296
+
297
+ // 配置等待超时(页面可调;推荐 180=3 分钟,最大 600=10 分钟,超上限自动截断)
298
+ export async function updateSettings({ timeoutSec } = {}) {
299
+ const n = Number(timeoutSec);
300
+ if (!Number.isFinite(n) || n <= 0) {
301
+ throw badInput(`超时时间需为 1-${MAX_TIMEOUT_SEC} 秒(推荐 ${DEFAULT_TIMEOUT_SEC})`);
302
+ }
303
+ const store = await loadStore();
304
+ store.settings = { timeoutSec: clampTimeoutSec(n) };
305
+ await saveStore(store);
306
+ return settingsView(store.settings);
307
+ }
308
+
309
+ export async function getTask({ cwd: rawCwd, id } = {}) {
310
+ const { key, cwd } = normalizeCwd(rawCwd);
209
311
  const want = Number(id);
210
312
  if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
211
313
  const store = await loadStore();
212
- const s = store.slots[key];
213
- const task = s && s.tasks.find((t) => Number(t.id) === want);
314
+ const t = store.topics[key];
315
+ const task = t && t.tasks.find((x) => Number(x.id) === want);
214
316
  if (!task) {
215
317
  const { notFound } = await import('../../core/errors.js');
216
- throw notFound(`现状「${summary}」里没有 #${want} 任务`);
318
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
217
319
  }
218
320
  return normalizeTask(task);
219
321
  }
220
322
 
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('任务内容不能为空');
323
+ /**
324
+ * 向某主题(工作目录)的队列回填任务。
325
+ * 若本进程正有 wait 在等该主题 → 直接投递并唤醒;否则入队堆积。
326
+ */
327
+ export async function addTask({ cwd: rawCwd, summary: rawSummary, text } = {}) {
328
+ const { key, cwd } = normalizeCwd(rawCwd);
329
+ const summary = normalizeSummary(rawSummary, { optional: true });
330
+ const body = normalizeText(text);
227
331
 
228
332
  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();
333
+ const t = topicOf(store, key, cwd);
334
+ const task = normalizeTask({ id: nextTaskId(t.tasks), text: body, status: 'pending', createdAt: stampNow() });
335
+ t.tasks.push(task);
336
+ t.updatedAt = stampNow();
337
+ if (summary) t.summary = summary;
233
338
 
234
339
  const waiter = waiters.get(key);
235
340
  const delivered = !!waiter;
236
341
  if (waiter) {
237
342
  markConsumed(task);
238
343
  waiters.delete(key);
239
- s.waiting = false;
240
- s.waitingUntil = '';
344
+ t.waiting = false;
345
+ t.waitingUntil = '';
241
346
  }
242
347
 
243
348
  await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
244
349
  if (waiter) waiter.resolve(normalizeTask(task));
245
350
 
246
- return { status: 'ok', key, summary, task: normalizeTask(task), delivered, queued: !delivered };
351
+ return {
352
+ status: 'ok',
353
+ key,
354
+ cwd,
355
+ name: t.name,
356
+ summary: t.summary,
357
+ task: normalizeTask(task),
358
+ delivered,
359
+ queued: !delivered,
360
+ };
247
361
  }
248
362
 
249
- export async function updateTask({ summary: rawSummary, id, text } = {}) {
250
- const summary = normalizeSummary(rawSummary);
251
- const key = keyForSummary(summary);
363
+ export async function updateTask({ cwd: rawCwd, id, text } = {}) {
364
+ const { key, cwd } = normalizeCwd(rawCwd);
252
365
  const want = Number(id);
253
366
  if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
254
367
  const body = String(text ?? '').trim();
255
368
  if (!body) throw badInput('--text 不能为空');
369
+ if (body.length > MAX_TEXT) throw badInput(`任务内容过长(≤${MAX_TEXT} 字)`);
256
370
 
257
371
  const store = await loadStore();
258
- const s = store.slots[key];
259
- const task = s && s.tasks.find((t) => Number(t.id) === want);
372
+ const t = store.topics[key];
373
+ const task = t && t.tasks.find((x) => Number(x.id) === want);
260
374
  if (!task) {
261
375
  const { notFound } = await import('../../core/errors.js');
262
- throw notFound(`现状「${summary}」里没有 #${want} 任务`);
376
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
263
377
  }
264
378
  task.text = body;
265
- s.updatedAt = stampNow();
379
+ t.updatedAt = stampNow();
266
380
  await saveStore(store);
267
- return { status: 'ok', key, summary, task: normalizeTask(task) };
381
+ return { status: 'ok', key, cwd, name: t.name, task: normalizeTask(task) };
268
382
  }
269
383
 
270
- export async function removeTask({ summary: rawSummary, id } = {}) {
271
- const summary = normalizeSummary(rawSummary);
272
- const key = keyForSummary(summary);
384
+ export async function removeTask({ cwd: rawCwd, id } = {}) {
385
+ const { key, cwd } = normalizeCwd(rawCwd);
273
386
  const want = Number(id);
274
387
  if (!Number.isFinite(want) || want <= 0) throw badInput('缺少参数 --id <任务编号>');
275
388
 
276
389
  const store = await loadStore();
277
- const s = store.slots[key];
278
- const index = s ? s.tasks.findIndex((t) => Number(t.id) === want) : -1;
390
+ const t = store.topics[key];
391
+ const index = t ? t.tasks.findIndex((x) => Number(x.id) === want) : -1;
279
392
  if (index < 0) {
280
393
  const { notFound } = await import('../../core/errors.js');
281
- throw notFound(`现状「${summary}」里没有 #${want} 任务`);
394
+ throw notFound(`主题「${t ? t.name : cwd}」里没有 #${want} 任务`);
282
395
  }
283
- const [removed] = s.tasks.splice(index, 1);
284
- s.updatedAt = stampNow();
396
+ const [removed] = t.tasks.splice(index, 1);
397
+ t.updatedAt = stampNow();
285
398
  await saveStore(store);
286
- return { status: 'ok', key, summary, removed: normalizeTask(removed) };
399
+ return { status: 'ok', key, cwd, name: t.name, removed: normalizeTask(removed) };
287
400
  }
288
401
 
289
402
  // ─── 阻塞等待:核心入口 ─────────────────────────────────────────────
290
403
 
291
404
  /**
292
- * 针对当前现状等待用户回填下一条任务。
293
- * @param {string} summary 当前现状总结(必填,作为等待点 key)
294
- * @param {number} timeoutMs 阻塞上限,默认 120000(2 分钟)
295
- * @param {boolean} open 是否自动打开浏览器弹窗(测试可关)
296
- * @param {function} onReady 服务器就绪回调 {addr,key,summary}(测试/集成钩子)
405
+ * 针对某主题(工作目录)等待用户回填下一条任务。
406
+ * @param {string} [cwd] 工作目录(主题按目录名识别);缺省 → 进程当前 cwd
407
+ * @param {string} summary 当前现状(必填,状态展示)
408
+ * @param {number} [timeoutSec] 阻塞上限(秒);缺省 → 页面配置(推荐 180,最大 600)
409
+ * @param {number} [timeoutMs] 兼容旧参数(毫秒)
410
+ * @param {boolean} [open] 是否自动打开浏览器弹窗(测试可关)
411
+ * @param {function} [onReady] 服务器就绪回调 {addr,key,cwd,name,summary}(测试/集成钩子)
297
412
  */
298
413
  export async function waitForTask({
414
+ cwd: rawCwd,
299
415
  summary: rawSummary,
300
- timeoutMs = DEFAULT_TIMEOUT_MS,
416
+ timeoutSec,
417
+ timeoutMs,
301
418
  open = true,
302
419
  onReady,
303
420
  } = {}) {
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;
421
+ const { key, cwd, name } = normalizeCwd(rawCwd);
422
+ const summary = normalizeSummary(rawSummary); // 现状必填
423
+
424
+ // 超时:显式 --timeout(秒/毫秒)优先;否则用页面配置;统一夹到 1-600
425
+ let sec;
426
+ if (timeoutSec !== undefined && timeoutSec !== null) sec = timeoutSec;
427
+ else if (timeoutMs !== undefined && timeoutMs !== null) sec = Number(timeoutMs) / 1000;
428
+ else sec = (await loadStore()).settings.timeoutSec;
429
+ const tmo = clampTimeoutSec(sec) * 1000;
308
430
 
309
- // 1. 等待点已有 pending → 立即取走,不开窗、不阻塞
431
+ // 1. 主题队列已有 pending → 立即取走,不开窗、不阻塞
310
432
  const queued = await consumeFirstPending(key);
311
- if (queued) return { status: 'ok', key, summary, task: queued, queued: true };
433
+ if (queued) return { status: 'ok', key, cwd, name, summary, task: queued, queued: true };
312
434
 
313
435
  // 2. 没有 pending → 起内嵌弹窗服务器
314
- const server = await startPopupServer(key, summary, tmo);
436
+ const server = await startPopupServer(key, cwd, summary, tmo);
315
437
  const addr = `http://127.0.0.1:${server.address().port}/`;
316
438
 
317
439
  // 3. 结果承诺:两个生产者(同进程 waiter / 跨进程轮询)+ 超时
@@ -325,8 +447,8 @@ export async function waitForTask({
325
447
 
326
448
  const poll = setInterval(() => {
327
449
  consumeFirstPending(key)
328
- .then((t) => {
329
- if (t) resolveOutcome({ kind: 'task', task: t, delivered: false });
450
+ .then((task) => {
451
+ if (task) resolveOutcome({ kind: 'task', task, delivered: false });
330
452
  })
331
453
  .catch(() => {
332
454
  /* 瞬时读写错误,下一轮再试 */
@@ -335,13 +457,13 @@ export async function waitForTask({
335
457
 
336
458
  const timer = setTimeout(() => resolveOutcome({ kind: 'timeout' }), tmo);
337
459
 
338
- // 登记「等待中」(含截止时刻),面板/弹窗据此显示倒计时
339
- await setWaiting(key, summary, true, new Date(Date.now() + tmo));
460
+ // 登记「等待中」(含截止时刻与现状),面板/弹窗据此显示倒计时
461
+ await setWaiting(key, cwd, summary, true, new Date(Date.now() + tmo));
340
462
 
341
463
  // waiter/轮询就位后再回调,保证 onReady 里立刻回填也能被接住
342
464
  if (typeof onReady === 'function') {
343
465
  try {
344
- await onReady({ addr, key, summary });
466
+ await onReady({ addr, key, cwd, name, summary });
345
467
  } catch {
346
468
  /* 钩子异常不得影响 wait */
347
469
  }
@@ -354,13 +476,13 @@ export async function waitForTask({
354
476
  const r = await outcome;
355
477
  result =
356
478
  r.kind === 'timeout'
357
- ? { status: 'timeout', key, summary, timeoutMs: tmo }
358
- : { status: 'ok', key, summary, task: r.task, delivered: r.delivered, queued: false };
479
+ ? { status: 'timeout', key, cwd, summary, timeoutMs: tmo }
480
+ : { status: 'ok', key, cwd, name, summary, task: r.task, delivered: r.delivered, queued: false };
359
481
  } finally {
360
482
  clearInterval(poll);
361
483
  clearTimeout(timer);
362
484
  waiters.delete(key);
363
- await setWaiting(key, summary, false);
485
+ await setWaiting(key, cwd, '', false);
364
486
  await stopServer(server);
365
487
  }
366
488
  return result;
@@ -409,13 +531,13 @@ function originAllowed(req) {
409
531
  }
410
532
  }
411
533
 
412
- async function handlePopup(req, res, key, summary, timeoutMs) {
534
+ async function handlePopup(req, res, key, cwd, summary, timeoutMs) {
413
535
  const url = new URL(req.url, 'http://127.0.0.1/');
414
536
  const method = (req.method || 'GET').toUpperCase();
415
537
 
416
538
  try {
417
539
  if ((method === 'GET' || method === 'HEAD') && (url.pathname === '/' || url.pathname === '/index.html')) {
418
- sendHtml(res, renderPopupPage({ key, summary, timeoutMs }));
540
+ sendHtml(res, renderPopupPage({ key, cwd, summary, timeoutMs }));
419
541
  return;
420
542
  }
421
543
 
@@ -430,7 +552,7 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
430
552
  return;
431
553
  }
432
554
  const body = await readBody(req);
433
- sendJson(res, 200, { ok: true, data: await addTask({ summary, text: body.text }) });
555
+ sendJson(res, 200, { ok: true, data: await addTask({ cwd, text: body.text }) });
434
556
  return;
435
557
  }
436
558
 
@@ -439,7 +561,7 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
439
561
  sendJson(res, 403, { ok: false, error: '跨站请求被拒绝(弹窗仅接受本机来源)', code: 'BLOCKED' });
440
562
  } else {
441
563
  const body = await readBody(req);
442
- sendJson(res, 200, { ok: true, data: await removeTask({ summary, id: body.id }) });
564
+ sendJson(res, 200, { ok: true, data: await removeTask({ cwd, id: body.id }) });
443
565
  }
444
566
  return;
445
567
  }
@@ -451,8 +573,8 @@ async function handlePopup(req, res, key, summary, timeoutMs) {
451
573
  }
452
574
  }
453
575
 
454
- function startPopupServer(key, summary, timeoutMs) {
455
- const server = http.createServer((req, res) => handlePopup(req, res, key, summary, timeoutMs));
576
+ function startPopupServer(key, cwd, summary, timeoutMs) {
577
+ const server = http.createServer((req, res) => handlePopup(req, res, key, cwd, summary, timeoutMs));
456
578
  return new Promise((resolve_, reject) => {
457
579
  server.once('error', reject);
458
580
  server.listen(0, '127.0.0.1', () => resolve_(server));