dsh-supercrew 0.1.5 → 0.1.8

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/bin/supercrew.mjs CHANGED
@@ -92,3 +92,11 @@ const child = spawn(process.execPath, args, {
92
92
  env,
93
93
  });
94
94
  child.on('exit', (code) => process.exit(code ?? 0));
95
+ // ★ spawn 失败是异步的:不监听的话,node 不在该在的位置时只打印一个未捕获
96
+ // 异常栈,用户看到的要么是「什么都没发生」、要么是一坨内部路径。
97
+ child.on('error', (e) => {
98
+ console.error(`起不来子命令 ${cmd}:${e.message}`);
99
+ console.error(` 用的 node: ${NODE}`);
100
+ console.error(` 要跑的脚本: ${path.join(ROOT, file)}`);
101
+ process.exit(1);
102
+ });
package/client.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * dsh-supercrew 的界面部分:会话头部一枚状态胶囊。
3
3
  *
4
- * 值来自看板的 /api/heartbeat(每 3 秒拉一次):
5
- * 🟢 值守中 · 344 轮 它盯着,手上没活
6
- * 🟡 正在干 TMWDxxxxxxxx 手上有一张单
4
+ * 值来自看板的 /api/heartbeat + /api/tickets(每 3 秒拉一次):
5
+ * 🟢 值守中 · 412 轮 它盯着,手上没活
6
+ * 🟡 正在干 2 张 手上正干着活
7
7
  * 🔴 值守掉线 心跳断了(或看板没起来)
8
8
  * 点一下打开看板。
9
9
  */
@@ -18,14 +18,21 @@ window.__ModuleLoader__.load({
18
18
 
19
19
  function Capsule() {
20
20
  const [hb, setHb] = React.useState(null);
21
+ const [busy, setBusy] = React.useState(0);
21
22
 
22
23
  React.useEffect(() => {
23
24
  let alive = true;
24
25
  const pull = async () => {
25
26
  try {
26
- const r = await fetch(`${PANEL}/api/heartbeat`, { cache: 'no-store' });
27
- const j = await r.json();
28
- if (alive) setHb(j);
27
+ const hbRes = await fetch(`${PANEL}/api/heartbeat`, { cache: 'no-store' });
28
+ const j = await hbRes.json();
29
+ let n = 0;
30
+ try {
31
+ const tRes = await fetch(`${PANEL}/api/tickets`, { cache: 'no-store' });
32
+ const list = await tRes.json();
33
+ if (Array.isArray(list)) n = list.filter((t) => t && t.state === 'working').length;
34
+ } catch { /* 工单拉不到不算错,胶囊照样显示值守状态 */ }
35
+ if (alive) { setHb(j); setBusy(n); }
29
36
  } catch {
30
37
  if (alive) setHb({ alive: false, offline: true });
31
38
  }
@@ -36,11 +43,12 @@ window.__ModuleLoader__.load({
36
43
  }, []);
37
44
 
38
45
  const offline = !hb || hb.alive === false;
39
- const working = !offline && typeof hb.note === 'string' && /正在干|接管待办/.test(hb.note);
46
+ const working = !offline && busy > 0;
40
47
  const color = offline ? '#e5484d' : (working ? '#f5a623' : '#2ecc71');
41
48
  const text = offline
42
49
  ? '值守掉线'
43
- : (working ? hb.note : `值守中${hb.rounds ? ` · ${hb.rounds} 轮` : ''}`);
50
+ : (working ? `正在干 ${busy} 张`
51
+ : `值守中${hb.rounds ? ` · ${hb.rounds} 轮` : ''}`);
44
52
 
45
53
  return h('button', {
46
54
  type: 'button',
package/crew-watch.mjs CHANGED
@@ -33,7 +33,17 @@ import { judge } from './lib/router.mjs';
33
33
  const SOCIAL = process.env.CREW_SOCIAL || 'D:\\sf\\surfcore-main\\state\\social-v2.json';
34
34
  const API = process.env.CREW_API || 'http://127.0.0.1:3100';
35
35
  const TOKENFILE = process.env.CREW_TOKENFILE || 'D:\\sf\\surfcore-main\\state\\console-token';
36
- const TRIGGER = new RegExp(process.env.CREW_TRIGGER || '@真白|@椎名真白|干活|派活|帮我做');
36
+ // 环境变量写坏了(比如 CREW_TRIGGER='@真白|(干活' 少个括号)不能让值守直接起不来
37
+ // —— 那等于整个系统全哑,而报错只会指向这一行正则,看不出是哪个变量干的。
38
+ const TRIGGER = (() => {
39
+ const raw = process.env.CREW_TRIGGER || '@真白|@椎名真白|干活|派活|帮我做';
40
+ try {
41
+ return new RegExp(raw);
42
+ } catch (e) {
43
+ console.error(`[crew-watch] CREW_TRIGGER 不是合法正则(${e.message}),退回默认值。`);
44
+ return new RegExp('@真白|@椎名真白|干活|派活|帮我做');
45
+ }
46
+ })();
37
47
  const SEEN = path.join(
38
48
  process.env.CREW_HOME || path.join(process.env.USERPROFILE || '.', '.dsh-supercrew'),
39
49
  'seen.json',
@@ -114,6 +124,7 @@ function bumpHeartbeat(patch = {}) {
114
124
  }
115
125
  if (patch.recent) h.recent = patch.recent.slice(0, 12);
116
126
  if (patch.note) h.note = patch.note;
127
+ if (patch.qq !== undefined) h.qq = patch.qq; // 耳朵还挂着吗(看板与状态胶囊上显示)
117
128
  try { saveJson(HEARTBEAT, h); } catch { /* 心跳写不进去不该影响值守 */ }
118
129
  return h;
119
130
  }
@@ -125,6 +136,29 @@ function markSeen(keys) {
125
136
  saveJson(SEEN, [...s].slice(-3000));
126
137
  }
127
138
 
139
+ /**
140
+ * 耳朵还在不在?
141
+ *
142
+ * 我们的 QQ 入口整个架在 surfcore 桥上:消息从桥写的 social-v2.json 里读,
143
+ * 回话走桥的控制台端口。桥要是没跑,我们就会一直对着一份冻结的文件「认真听」,
144
+ * 看起来像在干活,其实什么也收不到 —— 所以每一轮都先探一下端口,探不到就
145
+ * 明说,别再假装在听。
146
+ *
147
+ * 注意:桥对未授权的请求会回 401,那也算活着 —— 只要连得上就说明进程在。
148
+ */
149
+ export async function bridgeAlive(timeoutMs = 3000) {
150
+ const ctl = new AbortController();
151
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
152
+ try {
153
+ const r = await fetch(`${API}/api/status`, { signal: ctl.signal });
154
+ return r.status > 0; // 200/401/403 都说明端口有人听
155
+ } catch {
156
+ return false; // 连接被拒 / 超时 → 桥没跑
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+
128
162
  export function scan({ onlyNew = true } = {}) {
129
163
  const social = readSocial();
130
164
  const seen = seenSet();
@@ -161,8 +195,14 @@ export async function intake({ dryRun = false, concurrency = 4 } = {}) {
161
195
  try {
162
196
  // 门卫:@ 我一下不等于给我派了活。判不出就放过(宁可漏,不可瞎干)。
163
197
  const j = await judge(body);
198
+ // ★ 「判不出来」和「判成闲聊」必须分开:前者不记 seen(下一轮还能重判)。
199
+ // 混在一起的话,网关卡一下 / 超时一次,这批需求就被永久吞掉了。
200
+ if (j.ok === false) {
201
+ failed.push({ key: m.key, msgId: String(m.id ?? ''), text: body.slice(0, 40), why: j.reason });
202
+ continue;
203
+ }
164
204
  if (!j.isTask) {
165
- skipped.push({ key: m.key, from: m.from, text: body, why: j.reason });
205
+ skipped.push({ key: m.key, msgId: String(m.id ?? ''), from: m.from, text: body, why: j.reason });
166
206
  continue;
167
207
  }
168
208
  if (dryRun) { made.push({ dryRun: true, ...j, chatKey: m.key, msgId: m.id }); continue; }
@@ -188,7 +228,15 @@ export async function intake({ dryRun = false, concurrency = 4 } = {}) {
188
228
  });
189
229
  await Promise.all(lanes);
190
230
 
191
- if (!dryRun) markSeen(found.map(seenKey));
231
+ if (!dryRun) {
232
+ // 只标记「真正处理过的」:开单成功、或门卫确认是闲聊。
233
+ // 判不出来 / 开单出错的留着 —— 下一轮还能重判,不然需求就永久丢了。
234
+ const handled = new Set([
235
+ ...made.map((t) => `${t.chatKey}|${t.msgId}`),
236
+ ...skipped.map((s) => `${s.key}|${s.msgId}`),
237
+ ]);
238
+ markSeen(found.filter((m) => handled.has(`${m.key}|${m.id ?? ''}`)).map(seenKey));
239
+ }
192
240
 
193
241
  // 记心跳:这一轮扫到几条、开了几单、拦下几条、判过的都长什么样
194
242
  if (!dryRun) {
@@ -221,8 +269,24 @@ function sanitize(s) {
221
269
 
222
270
  /** 把结果发回它来的会话;失败会把原因记进工单,方便事后补发 */
223
271
  export async function reply(ticket) {
272
+ // ★ 失败要留痕:没有来源会话 / 没有 agentToken / 令牌文件读不到,是最常见的三个
273
+ // 失败点。工单 done 之后不会再跑,这类失败要是直接 throw,看板上什么都不显示,
274
+ // 结果就永远没发出去而没人知道。所以一律写 replyError 再返回。
275
+ const fail = (msg) => {
276
+ try {
277
+ const fresh = loadAllTickets().find((t) => t.id === ticket.id);
278
+ if (fresh) {
279
+ fresh.replyAt = new Date().toISOString();
280
+ fresh.replyStatus = -1;
281
+ fresh.replyError = msg;
282
+ saveTicket(fresh);
283
+ }
284
+ } catch { /* 记账失败就算了,别因为记账影响主流程 */ }
285
+ return { status: -1, body: msg };
286
+ };
287
+
224
288
  const key = ticket.chatKey;
225
- if (!key) throw new Error('这张工单没有来源会话');
289
+ if (!key) return fail('这张工单没有来源会话');
226
290
 
227
291
  // 两个令牌都要带:
228
292
  // ① x-console-token —— 桥的全局关卡(所有 /api/* 都查它)
@@ -232,8 +296,10 @@ export async function reply(ticket) {
232
296
  const social = readSocial();
233
297
  const conv = (social.conversations || {})[key] || {};
234
298
  const agentToken = conv.agentToken || '';
235
- if (!agentToken) throw new Error(`会话 ${key} 没有 agentToken,发不出去`);
236
- const consoleToken = fs.readFileSync(TOKENFILE, 'utf8').trim();
299
+ if (!agentToken) return fail(`会话 ${key} 没有 agentToken,发不出去`);
300
+ let consoleToken = '';
301
+ try { consoleToken = fs.readFileSync(TOKENFILE, 'utf8').trim(); }
302
+ catch (e) { return fail(`读不到控制台令牌(${TOKENFILE}):${e.message}`); }
237
303
 
238
304
  const isGroup = key.startsWith('group:');
239
305
  const id = key.split(':')[1];
@@ -304,37 +370,91 @@ async function main() {
304
370
  } else if (cmd === 'loop') {
305
371
  console.log('守着…… Ctrl+C 退出');
306
372
  bumpHeartbeat({ note: '值守起来了' }); // 先写一条,让看板立刻看到它还活着
373
+ // ★ 独立的心跳:不管下面那圈卡在哪个 worker 上,心跳照跳。
374
+ // 以前没有它的时候,一旦接管了一张要跑十分钟的单,心跳就跟着停,
375
+ // 看板(和会话头部那枚胶囊)会显示「值守掉线」—— 明明它在干活。
376
+ const beat = setInterval(() => { try { bumpHeartbeat({}); } catch { /* ignore */ } }, 8000);
377
+ beat.unref?.();
378
+ let qqDownRounds = 0; // 桥断了几轮(断着的时候不重复刷屏)
379
+ const running = new Set(); // 正在后台跑的单,别重复派
307
380
  for (;;) {
308
381
  try {
309
382
  // 先接管「开了但没人派」的单 —— 从看板输入框、CLI 丢进来的活都落在 queued,
310
383
  // 以前必须有人去点卡片上的按钮才会跑,所以看起来像「它一点不干」。
311
384
  // CREW_AUTO_RUN=0 可以关掉这个行为(只想手动派的时候)。
385
+ //
386
+ // ★ 不 await:worker 一张单可能跑十分钟,等它就把整圈堵死了(新单收不到、
387
+ // 心跳也停)。这里只把「谁在跑」记进 running,跑完由回调收尾。
312
388
  if (process.env.CREW_AUTO_RUN !== '0') {
313
- const queued = loadAllTickets().filter((t) => t.state === 'queued');
389
+ // 一次能同时开几个 worker。每张单是一个独立的 headless 进程(独立上下文、
390
+ // 独立工作目录),并行才叫团队;但每个进程都吃内存,所以要有个上限。
391
+ const MAX_WORKERS = Math.max(1, Number(process.env.CREW_MAX_WORKERS || 3));
392
+ const room = MAX_WORKERS - running.size;
393
+ const queued = room > 0
394
+ ? loadAllTickets().filter((t) => t.state === 'queued' && !running.has(t.id)).slice(0, room)
395
+ : [];
314
396
  for (const q of queued) {
397
+ running.add(q.id);
315
398
  console.log(`⏳ 接管待办 ${q.id} ${q.title}`);
316
399
  bumpHeartbeat({ note: `接管待办 ${q.id}:${q.title}` });
317
- const done = await runTicket(q.id);
318
- if (!done) { console.log(' (被别的进程抢走了)'); continue; }
319
- console.log(` → ${done.state} ${(done.result || done.error || '').slice(0, 120)}`);
320
- if (done.chatKey) {
321
- const rr = await reply(done);
322
- console.log(` 结果回传:${rr.status}`);
323
- }
324
- bumpHeartbeat({ note: `${q.id} ${done.state}` });
400
+ (async () => {
401
+ try {
402
+ const done = await runTicket(q.id);
403
+ if (!done) { console.log(' (被别的进程抢走了)'); return; }
404
+ console.log(` → ${done.state} ${(done.result || done.error || '').slice(0, 120)}`);
405
+ if (done.chatKey) {
406
+ const rr = await reply(done);
407
+ console.log(` 结果回传:${rr.status}`);
408
+ }
409
+ bumpHeartbeat({ note: `${q.id} ${done.state}` });
410
+ } catch (e) {
411
+ console.log(` ✗ ${q.id} 出错:${e.message}`);
412
+ bumpHeartbeat({ note: `${q.id} 出错:${e.message}` });
413
+ } finally {
414
+ running.delete(q.id);
415
+ }
416
+ })();
325
417
  }
326
418
  }
327
419
 
328
- const r = await intake();
329
- if (r.made.length || r.failed.length) showJudged(r);
330
- for (const t of r.made) {
331
- console.log(`▶ ${t.id} ${t.title}`);
332
- bumpHeartbeat({ note: `正在干 ${t.id}:${t.title}` });
333
- const done = await runTicket(t.id);
334
- if (!done) { console.log(' (被别的进程抢走了)'); continue; }
335
- const rr = await reply(done);
336
- console.log(` 结果回传:${rr.status}${rr.status >= 300 ? ` —— ${String(rr.body).slice(0, 120)}` : ''}`);
337
- bumpHeartbeat({ note: `${t.id} 干完,结果回传 ${rr.status}` });
420
+ // 耳朵还挂着吗?桥没跑就别对着那份冻结的文件继续听 —— 但只跳 QQ 这一段,
421
+ // 上面的「接管待办」和下面的循环照旧,工作区的活不受影响。
422
+ const up = await bridgeAlive();
423
+ if (!up) {
424
+ qqDownRounds += 1;
425
+ bumpHeartbeat({
426
+ qq: false,
427
+ note: qqDownRounds === 1
428
+ ? 'QQ 入口断了(surfcore 桥没在跑)· 只盯工作区'
429
+ : `QQ 入口断着(已 ${qqDownRounds} 轮)· 只盯工作区`,
430
+ });
431
+ } else {
432
+ qqDownRounds = 0;
433
+ bumpHeartbeat({ qq: true });
434
+ const r = await intake();
435
+ if (r.made.length || r.failed.length) showJudged(r);
436
+ for (const t of r.made) {
437
+ if (running.has(t.id)) continue;
438
+ // ★ 这里也不能 await:一张 QQ 单跑十分钟,等的就是这个「耳朵」被堵死的样子。
439
+ // 丢进后台、记进 running,跑完(含回传)由回调收尾。
440
+ running.add(t.id);
441
+ console.log(`▶ ${t.id} ${t.title}`);
442
+ bumpHeartbeat({ note: `正在干 ${t.id}:${t.title}` });
443
+ (async () => {
444
+ try {
445
+ const done = await runTicket(t.id);
446
+ if (!done) { console.log(' (被别的进程抢走了)'); return; }
447
+ const rr = await reply(done);
448
+ console.log(` 结果回传:${rr.status}${rr.status >= 300 ? ` —— ${String(rr.body).slice(0, 120)}` : ''}`);
449
+ bumpHeartbeat({ note: `${t.id} 干完,结果回传 ${rr.status}` });
450
+ } catch (e) {
451
+ console.log(` ✗ ${t.id} 出错:${e.message}`);
452
+ bumpHeartbeat({ note: `${t.id} 出错:${e.message}` });
453
+ } finally {
454
+ running.delete(t.id);
455
+ }
456
+ })();
457
+ }
338
458
  }
339
459
  } catch (e) {
340
460
  console.error('这一轮出错:', e.message);
package/crew.mjs CHANGED
@@ -126,7 +126,14 @@ function loadOne(id) {
126
126
  }
127
127
 
128
128
  function newId() {
129
- return 'T' + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2, 4).toUpperCase();
129
+ // 原来只有 2 位随机后缀,同一毫秒内连开几张单有小概率撞号 —— 撞上就是
130
+ // 后一张覆盖前一张的 JSON。改成 8 位后缀 + 落盘前确认文件确实不存在。
131
+ for (let i = 0; i < 20; i++) {
132
+ const id = 'T' + Date.now().toString(36).toUpperCase()
133
+ + Math.random().toString(36).slice(2, 10).toUpperCase();
134
+ if (!fs.existsSync(tpath(id))) return id;
135
+ }
136
+ throw new Error('生成工单号失败:连续 20 次都撞上了(系统时间被拨回去了?)');
130
137
  }
131
138
 
132
139
  export function addTicket({ title, body, cwd, source = 'cli', requester = '', chatKey = '', msgId = '' }) {
@@ -184,8 +191,14 @@ function acquireLock(id) {
184
191
  try { holder = Number(fs.readFileSync(p, 'utf8')) || 0; } catch { /* 读不到就当死的 */ }
185
192
  if (!pidAlive(holder)) {
186
193
  log(`发现 ${id} 的陈旧锁(pid ${holder || '未知'} 已不在),抢过来`);
187
- try { fs.unlinkSync(p); } catch { /* 别人先删了也行 */ }
188
- try { return write(); } catch { return null; }
194
+ // 删锁和重建之间有个空窗:另一个进程可能正好在这时抢到。抢不到就重试,
195
+ // 连试三次还不行就认输 —— 不重试的话,两个进程同时发现陈旧锁时,
196
+ // 其中一个会白等一整轮才等到下次机会。
197
+ for (let i = 0; i < 3; i++) {
198
+ try { fs.unlinkSync(p); } catch { /* 别人先删了也行 */ }
199
+ try { return write(); } catch { /* 又被人抢了,再试 */ }
200
+ }
201
+ return null;
189
202
  }
190
203
  return null;
191
204
  }
@@ -356,7 +369,12 @@ function runWorkerOnce(ticket, onSpawn, liveFile, onWarn) {
356
369
  err += `\n[crew] worker 超过 ${Math.round(TIMEOUT / 1000)} 秒仍未结束,已强杀整棵进程树`;
357
370
  feed(`\n[crew] 超过 ${Math.round(TIMEOUT / 1000)} 秒未结束,超时强杀\n`);
358
371
  try {
359
- spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
372
+ const tk = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
373
+ // spawn 的失败是异步 'error' 事件,外面这层 try/catch 抓不到 —— 没人监听就是
374
+ // 未捕获异常,会把值守整个带崩(而此刻可能还有别的 worker 在跑),
375
+ // 本该兜底的 child.kill 也永远走不到。
376
+ tk.on('error', () => { try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ } });
377
+ tk.on('close', (c) => { if (c !== 0) { try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ } } });
360
378
  } catch {
361
379
  try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ }
362
380
  }
@@ -369,9 +387,16 @@ function runWorkerOnce(ticket, onSpawn, liveFile, onWarn) {
369
387
  child.stdout.on('data', (d) => { checkMojibake(d); if (out.length < MAX_OUT) out += d; feed(d); });
370
388
  child.stderr.on('data', (d) => { checkMojibake(d); if (err.length < MAX_OUT) err += d; feedErr(d); });
371
389
 
372
- child.on('error', (e) => { clearTimeout(timer); resolve({ rc: -1, out: cap(out), err: cap(err + '\n' + e.message), seconds: 0, pid: child.pid }); });
373
- child.on('close', (rc) => { clearTimeout(timer); resolve({ rc, out: cap(out), err: cap(err), seconds: (Date.now() - started) / 1000, pid: child.pid }); });
374
- process.once('exit', () => { if (!killed && child.pid) { killed = true; try { child.kill(); } catch { /* 尽力而为 */ } } });
390
+ // ★ exit 兜底必须在收尾时摘掉。它是每派一张单加一个监听器,而 process 默认上限
391
+ // 10 个 —— 值守跑十几张单就开始刷 MaxListenersExceededWarning;更糟的是每个
392
+ // 监听器都攥着 child(连带它的 stdout/stderr 缓冲,各上限 1MB),已完成的单
393
+ // 输出永远不回收,值守内存只涨不落。
394
+ const onExit = () => { if (!killed && child.pid) { killed = true; try { child.kill(); } catch { /* 尽力而为 */ } } };
395
+ process.once('exit', onExit);
396
+ const unhook = () => { try { process.removeListener('exit', onExit); } catch { /* 尽力而为 */ } };
397
+
398
+ child.on('error', (e) => { clearTimeout(timer); unhook(); resolve({ rc: -1, out: cap(out), err: cap(err + '\n' + e.message), seconds: 0, pid: child.pid }); });
399
+ child.on('close', (rc) => { clearTimeout(timer); unhook(); resolve({ rc, out: cap(out), err: cap(err), seconds: (Date.now() - started) / 1000, pid: child.pid }); });
375
400
  });
376
401
  }
377
402
 
@@ -527,15 +552,27 @@ async function main() {
527
552
  console.log(' 下一步: node crew.mjs run');
528
553
  } else if (cmd === 'run') {
529
554
  const all = !!opts.all;
530
- do {
531
- const q = pending();
532
- if (!q.length) { console.log('没有排队的工单。'); break; }
555
+ // ★ 抢不到锁时不能直接 continue:pending() 仍然会把那张单排在第一,于是
556
+ // do/while 原地空转,CPU 打满却什么也不干(review.md #6)。记下来跳过。
557
+ const skipped = new Set();
558
+ for (;;) {
559
+ const q = pending().filter((t) => !skipped.has(t.id));
560
+ if (!q.length) {
561
+ console.log(skipped.size ? '剩下的都被别的进程处理着。' : '没有排队的工单。');
562
+ break;
563
+ }
533
564
  const t = q[0];
534
565
  console.log(`▶ 派工 ${t.id}:${t.title}`);
535
566
  const done = await runTicket(t.id);
536
- if (!done) { console.log(' (被别的进程抢走了,换下一张)'); continue; }
567
+ if (!done) {
568
+ skipped.add(t.id);
569
+ console.log(' (被别的进程抢走了,跳过这张)');
570
+ continue;
571
+ }
537
572
  console.log(` ${done.state === 'done' ? '✔ 完成' : '✘ 失败'} ${done.state === 'done' ? done.result.slice(0, 200) : done.error}`);
538
- } while (all);
573
+ skipped.clear(); // 干完一张,之前卡住的单可能已经松手了,重新扫一遍
574
+ if (!all) break;
575
+ }
539
576
  } else if (cmd === 'requeue') {
540
577
  const staleMin = Number(typeof opts.stale === 'string' ? opts.stale : STALE_MIN);
541
578
  const moved = requeueStale({ staleMin, id: typeof opts.id === 'string' ? opts.id : null });
package/lib/router.mjs CHANGED
@@ -74,6 +74,7 @@ export async function judge(text, { context = '' } = {}) {
74
74
  const r = await askOnce(model, user);
75
75
  if (r.ok) {
76
76
  return {
77
+ ok: true,
77
78
  isTask: !!r.o.isTask,
78
79
  title: (r.o.title || '').slice(0, 40),
79
80
  body: r.o.body || text,
@@ -86,7 +87,10 @@ export async function judge(text, { context = '' } = {}) {
86
87
  tried.push(`${model}: ${e.message}`);
87
88
  }
88
89
  }
89
- return { isTask: false, reason: '所有模型都没判出来(保守放过)', tried };
90
+ // ★ 这里必须和「真的是闲聊」分开:调用方(crew-watch)只对 ok:true 且 isTask:false
91
+ // 的消息记 seen。判不出来还记 seen 的话,网关卡一下、超时一次,那批需求就被
92
+ // 永久吞掉了 —— 下次再扫到也不认。
93
+ return { ok: false, isTask: null, reason: '所有模型都没判出来(保守放过,不记 seen)', tried };
90
94
  }
91
95
 
92
96
  // 直接命令行跑:node router.mjs "帮我写个 python 脚本算质数"
package/package.json CHANGED
@@ -1,79 +1,81 @@
1
- {
2
- "name": "dsh-supercrew",
3
- "version": "0.1.5",
4
- "private": false,
5
- "type": "module",
6
- "description": "给 DeepSeek Harness 装一个团队:收需求 → 门卫判断 → 开工单 → 派独立 worker 干活 → 结果回报。模型可配(默认用你当前在用的,免费池当备选),不依赖 tmux。",
7
- "keywords": [
8
- "dsh",
9
- "dsh-plugin",
10
- "deepseek-harness",
11
- "agent",
12
- "orchestration",
13
- "multi-agent",
14
- "teams",
15
- "superagent",
16
- "task-queue"
17
- ],
18
- "license": "MIT",
19
- "author": "dafeiyu",
20
- "engines": {
21
- "node": ">=22.19.0",
22
- "dsh": ">=0.2.2-rc.1"
23
- },
24
- "main": "lib/index.js",
25
- "exports": {
26
- ".": "./lib/index.js",
27
- "./client": "./client.js"
28
- },
29
- "bin": {
30
- "supercrew": "./bin/supercrew.mjs"
31
- },
32
- "dsh": {
33
- "bundle": {
34
- "patch": "./cordis.patch.yml"
35
- },
36
- "client": {
37
- "platform": "web",
38
- "immediately": true,
39
- "inject": ["@deepseek-ai/dsh-client-ui-conversation"]
40
- }
41
- },
42
- "files": [
43
- "lib/",
44
- "bin/",
45
- "skills/",
46
- "panel.mjs",
47
- "crew.mjs",
48
- "crew-watch.mjs",
49
- "client.js",
50
- "worker-models.yml",
51
- "cordis.patch.yml",
52
- "crew_guard.ps1",
53
- "README.md",
54
- "INSTALL.md",
55
- "DESIGN.md",
56
- "LICENSE",
57
- "selftest.mjs",
58
- "worker-brief.md"
59
- ],
60
- "publishConfig": {
61
- "access": "public"
62
- },
63
- "scripts": {
64
- "panel": "node panel.mjs",
65
- "watch": "node crew-watch.mjs loop",
66
- "selftest": "node selftest.mjs",
67
- "check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check client.js && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs",
68
- "selftest:quick": "node selftest.mjs --quick",
69
- "work": "node crew.mjs work"
70
- },
71
- "repository": {
72
- "type": "git",
73
- "url": "https://github.com/wesqaz111/dsh-supercrew.git"
74
- },
75
- "homepage": "https://github.com/wesqaz111/dsh-supercrew#readme",
76
- "bugs": {
77
- "url": "https://github.com/wesqaz111/dsh-supercrew/issues"
78
- }
79
- }
1
+ {
2
+ "name": "dsh-supercrew",
3
+ "version": "0.1.8",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "给 DeepSeek Harness 装一个团队:收需求 → 门卫判断 → 开工单 → 派独立 worker 干活 → 结果回报。模型可配(默认用你当前在用的,免费池当备选),不依赖 tmux。",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "deepseek-harness",
11
+ "agent",
12
+ "orchestration",
13
+ "multi-agent",
14
+ "teams",
15
+ "superagent",
16
+ "task-queue"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "dafeiyu",
20
+ "engines": {
21
+ "node": ">=22.19.0",
22
+ "dsh": ">=0.2.2-rc.1"
23
+ },
24
+ "main": "lib/index.js",
25
+ "exports": {
26
+ ".": "./lib/index.js",
27
+ "./client": "./client.js"
28
+ },
29
+ "bin": {
30
+ "supercrew": "./bin/supercrew.mjs"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ]
42
+ }
43
+ },
44
+ "files": [
45
+ "lib/",
46
+ "bin/",
47
+ "skills/",
48
+ "panel.mjs",
49
+ "crew.mjs",
50
+ "crew-watch.mjs",
51
+ "client.js",
52
+ "worker-models.yml",
53
+ "cordis.patch.yml",
54
+ "crew_guard.ps1",
55
+ "README.md",
56
+ "INSTALL.md",
57
+ "DESIGN.md",
58
+ "LICENSE",
59
+ "selftest.mjs",
60
+ "worker-brief.md"
61
+ ],
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "scripts": {
66
+ "panel": "node panel.mjs",
67
+ "watch": "node crew-watch.mjs loop",
68
+ "selftest": "node selftest.mjs",
69
+ "check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check client.js && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs",
70
+ "selftest:quick": "node selftest.mjs --quick",
71
+ "work": "node crew.mjs work"
72
+ },
73
+ "repository": {
74
+ "type": "git",
75
+ "url": "https://github.com/wesqaz111/dsh-supercrew.git"
76
+ },
77
+ "homepage": "https://github.com/wesqaz111/dsh-supercrew#readme",
78
+ "bugs": {
79
+ "url": "https://github.com/wesqaz111/dsh-supercrew/issues"
80
+ }
81
+ }
package/panel.mjs CHANGED
@@ -19,7 +19,8 @@
19
19
  import http from 'node:http';
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
- import { loadAllTickets, addTicket, runTicket } from './crew.mjs';
22
+ import { loadAllTickets, addTicket, saveTicket, runTicket } from './crew.mjs';
23
+ import { judge } from './lib/router.mjs';
23
24
 
24
25
  function argPort() {
25
26
  const argv = process.argv.slice(2);
@@ -44,9 +45,14 @@ function tail(file, maxBytes = 24 * 1024) {
44
45
  const from = Math.max(0, st.size - maxBytes);
45
46
  const fd = fs.openSync(file, 'r');
46
47
  try {
48
+ // readSync 只保证「最多读这么多」,短读时缓冲区尾部是未写入的 \0 —— 直接
49
+ // 全域 toString 会把一串 NUL 塞进浏览器。用返回值 n 截断。
47
50
  const buf = Buffer.alloc(Math.min(maxBytes, st.size - from));
48
- fs.readSync(fd, buf, 0, buf.length, from);
49
- return buf.toString('utf8');
51
+ const n = fs.readSync(fd, buf, 0, buf.length, from);
52
+ let s = buf.subarray(0, n).toString('utf8');
53
+ // from > 0 时起点很可能劈开一个汉字,开头会出现 或半行 —— 丢掉第一行残片
54
+ if (from > 0) s = s.replace(/^[^\n]*\n/, '');
55
+ return s;
50
56
  } finally { fs.closeSync(fd); }
51
57
  } catch { return ''; }
52
58
  }
@@ -363,6 +369,20 @@ const server = http.createServer(async (req, res) => {
363
369
  res.end(JSON.stringify(o));
364
370
  };
365
371
  try {
372
+ // ★ 防跨站派工:本机任意网页都能 POST 到这里,而 /api/add 会立刻派一个
373
+ // --permission-mode danger-full-access 的 worker —— 等于「打开一个网页就能让
374
+ // 这台机器执行任意命令」。浏览器发起的请求一定带 Origin,本机脚本不会带。
375
+ // 所以:写操作(POST)带 Origin 一律拒;再要求 content-type 是 application/json
376
+ // (simple request 只能发 text/plain,加这一条把不触发预检的那条路也堵掉)。
377
+ // 读操作(GET)不拦 —— 会话头部那枚胶囊就是从 DSH 页面跨端口来读心跳的。
378
+ if (req.method === 'POST') {
379
+ if (req.headers.origin) {
380
+ return json({ error: '拒绝来自浏览器的写调用(防跨站派工);请用本机脚本或 crew.mjs' }, 403);
381
+ }
382
+ if (!String(req.headers['content-type'] || '').includes('application/json')) {
383
+ return json({ error: 'content-type 必须是 application/json' }, 415);
384
+ }
385
+ }
366
386
  if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
367
387
  res.writeHead(200, {
368
388
  'content-type': 'text/html; charset=utf-8',
@@ -395,7 +415,17 @@ const server = http.createServer(async (req, res) => {
395
415
  t.liveTail = lastLine(path.join(RUNS, `${t.id}.live.log`)) || '';
396
416
  }
397
417
  }
398
- return json(list);
418
+ // ★ 只回列表要用的字段,result/error 截到 2000 字。整份可能是 1MB × 单数,
419
+ // 而前端只显示 600 字 —— 每 5 秒轮询传几 MB JSON,浏览器和 node 都会卡。
420
+ // 要看全文有 /api/live/:id。
421
+ return json(list.map((t) => ({
422
+ id: t.id, title: t.title, state: t.state, source: t.source,
423
+ createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt,
424
+ pid: t.pid, attempts: t.attempts, warn: t.warn, replyError: t.replyError,
425
+ liveTail: t.liveTail,
426
+ result: t.result ? String(t.result).slice(0, 2000) : null,
427
+ error: t.error ? String(t.error).slice(0, 2000) : null,
428
+ })));
399
429
  }
400
430
 
401
431
  if (req.method === 'GET' && url.pathname.startsWith('/api/live/')) {
@@ -444,6 +474,55 @@ const server = http.createServer(async (req, res) => {
444
474
  return json(t);
445
475
  }
446
476
 
477
+ // POST /api/ask —— 让「别的程序」把「有人叫我了」递进来。
478
+ //
479
+ // 为什么需要它:QQ 那边的入口原本架在 surfcore 桥上(读它写的 social-v2.json)。
480
+ // 桥停了以后,我们这边就收不到任何东西了。现在换一条不依赖桥的路:
481
+ // 谁听见了消息,就把它整句 POST 到这里,门卫在这里判 —— 是活就开单派出去,
482
+ // 是闲聊就只回一句「没开单」,不占工单列表。
483
+ if (req.method === 'POST' && url.pathname === '/api/ask') {
484
+ let raw = '';
485
+ for await (const c of req) raw += c;
486
+ let b = {};
487
+ try { b = JSON.parse(raw || '{}'); } catch { return json({ error: '请求体不是合法 JSON' }, 400); }
488
+ const text = String(b.text || '').trim();
489
+ if (!text) return json({ error: 'text 不能为空' }, 400);
490
+ const chatKey = b.chatKey ? String(b.chatKey) : '';
491
+ const from = b.from ? String(b.from) : '';
492
+
493
+ let j;
494
+ try {
495
+ j = await judge(text);
496
+ } catch (e) {
497
+ // 门卫挂了不该当成「那就干吧」—— 宁可漏,也不瞎干
498
+ return json({ isTask: null, error: `门卫没判出来:${e.message}` });
499
+ }
500
+ if (!j.isTask) {
501
+ return json({ isTask: false, why: j.reason || '', text: text.slice(0, 80) });
502
+ }
503
+
504
+ const t = addTicket({
505
+ body: j.body || text,
506
+ title: j.title || text.slice(0, 40),
507
+ cwd: b.cwd || process.env.CREW_WORKDIR || process.cwd(),
508
+ source: chatKey ? `qq:${chatKey}` : (b.source ? String(b.source) : 'ask'),
509
+ requester: from,
510
+ chatKey, // 回话要用(reply 靠它找原会话)
511
+ msgId: b.msgId ? String(b.msgId) : '',
512
+ });
513
+ t.judged = { reason: j.reason, by: j.by, original: text };
514
+ saveTicket(t);
515
+
516
+ if (b.autoRun !== false && !running.has(t.id)) {
517
+ running.add(t.id);
518
+ runTicket(t.id)
519
+ .then((done) => { if (done) console.log(`[ask] ${t.id} → ${done.state}`); })
520
+ .catch((e) => console.error(`[ask] ${t.id} 派工出错:${e.message}`))
521
+ .finally(() => running.delete(t.id));
522
+ }
523
+ return json({ isTask: true, id: t.id, title: t.title, why: j.reason || '' });
524
+ }
525
+
447
526
  if (req.method === 'POST' && url.pathname.startsWith('/api/run/')) {
448
527
  const id = decodeURIComponent(url.pathname.slice('/api/run/'.length));
449
528
  if (!/^[A-Za-z0-9_-]+$/.test(id)) return json({ error: 'id 不合法' }, 400);
@@ -463,6 +542,18 @@ const server = http.createServer(async (req, res) => {
463
542
  }
464
543
  });
465
544
 
545
+ // ★ 端口被占时 listen 的失败是异步事件 —— 没人监听就是未捕获异常,栈里只有
546
+ // EADDRINUSE,看不出是「谁」占了。
547
+ server.on('error', (e) => {
548
+ if (e && e.code === 'EADDRINUSE') {
549
+ console.error(`端口 ${PORT} 已被占用:先跑 netstat -ano | findstr :${PORT} 看是谁,`
550
+ + `或者换一个 --port=<其他端口>。`);
551
+ } else {
552
+ console.error('看板起不来:', e);
553
+ }
554
+ process.exit(1);
555
+ });
556
+
466
557
  server.listen(PORT, '127.0.0.1', () => {
467
558
  console.log(`团队看板:http://127.0.0.1:${PORT}/`);
468
559
  console.log(`数据目录:${HOME}`);
package/selftest.mjs CHANGED
@@ -37,11 +37,28 @@ function check(name, ok, detail) {
37
37
  function run(cmd, args, opts = {}) {
38
38
  return new Promise((resolve) => {
39
39
  const p = spawn(cmd, args, { windowsHide: true, ...opts });
40
- let out = '', err = '';
40
+ let out = '', err = '', done = false;
41
+ // ★ 不设编码时 chunk 边界会把中文劈成乱码 —— 下面「worker 读中文」那项就会
42
+ // 误报失败(自检说系统坏了,其实是自检自己坏了)。
43
+ p.stdout.setEncoding('utf8');
44
+ p.stderr.setEncoding('utf8');
45
+ // ★ 没有超时会永久挂住:worker 起浏览器不退时实测过,自检就卡在那儿不说话。
46
+ const limit = opts.timeoutMs || 120000;
47
+ const timer = setTimeout(() => {
48
+ if (done) return;
49
+ done = true;
50
+ try {
51
+ spawn('taskkill', ['/PID', String(p.pid), '/T', '/F'], { windowsHide: true })
52
+ .on('error', () => { /* 杀不掉就算了,至少不再等它 */ });
53
+ } catch { /* 尽力而为 */ }
54
+ resolve({ rc: -2, out, err: err + `\n(超过 ${limit}ms 未结束,已强杀)` });
55
+ }, limit);
56
+ if (timer.unref) timer.unref();
57
+ const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
41
58
  p.stdout.on('data', (d) => { out += d; });
42
59
  p.stderr.on('data', (d) => { err += d; });
43
- p.on('error', (e) => resolve({ rc: -1, out, err: String(e.message) }));
44
- p.on('close', (rc) => resolve({ rc, out, err }));
60
+ p.on('error', (e) => finish({ rc: -1, out, err: String(e.message) }));
61
+ p.on('close', (rc) => finish({ rc, out, err }));
45
62
  });
46
63
  }
47
64
 
package/worker-brief.md CHANGED
@@ -7,7 +7,7 @@
7
7
  你手上有 **read / glob / grep 这些文件工具**,它们会自动处理编码。**读文件就用它们。**
8
8
 
9
9
  不要用 shell 去读文件 —— 尤其不要用 PowerShell 的 `Get-Content`:这台机器上它默认按
10
- GBK 解码,读 UTF-8 的中文文件会变乱码(`鈥斺€?` 这种),**而且行数也会算错**
10
+ GBK 解码,读 UTF-8 的中文文件会变乱码(看起来像一堆方块、问号、`é–` 这类怪符号),**而且行数也会算错**
11
11
  (GBK 解码器会把换行字节当成多字节字符的一部分吃掉,几行会并成一行)。
12
12
 
13
13
  只有 read 工具做不了的事(数行数、批量统计),才用 **python**:
@@ -23,7 +23,7 @@ Python 在 `D:\Python312\python.exe`(**不在 PATH,要写全路径**)。
23
23
 
24
24
  动手之前,先确认你能正确读到中文:用 **read 工具**读一个含中文的文件,看一眼中文是不是正常的。
25
25
 
26
- 若显示成 `鈥斺€?` 这类乱码,说明你走错路了 —— 换回 read 工具,或者用 python 指定 `encoding='utf-8'`。
26
+ 若显示成一堆怪符号(方块、问号、`é–` 之类),说明你走错路了 —— 换回 read 工具,或者用 python 指定 `encoding='utf-8'`。
27
27
 
28
28
  这 30 秒能省掉十分钟的绕圈。
29
29