@iamsamyiok/agents-chat 3.19.2 → 3.21.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.
package/app/lib/cards.js CHANGED
@@ -22,27 +22,79 @@ const TRASH_PATH = path.join(DATA_DIR, 'cards_trash.json');
22
22
  const TRASH_TTL = 30 * 24 * 3600 * 1000; // 垃圾桶默认保留 30 天
23
23
 
24
24
  function ensureDir() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); }
25
+ // 数据损坏保护:解析失败(文件存在但 JSON 损坏)时备份原文件并置损坏标记;
26
+ // 后续写入直接拒绝,防止「读到空列表 → 新增一条 → 覆盖写」把用户全部卡牌冲掉
27
+ const corrupted = new Set(); // 已损坏的文件路径
28
+ function cachedReader(file, cacheBox) {
29
+ return function read() {
30
+ if (corrupted.has(file)) return [];
31
+ try {
32
+ const st = fs.statSync(file);
33
+ if (cacheBox.list && st.mtimeMs === cacheBox.mtime) return cacheBox.list;
34
+ const list = JSON.parse(fs.readFileSync(file, 'utf8'));
35
+ cacheBox.mtime = st.mtimeMs; cacheBox.list = list;
36
+ return list;
37
+ } catch (err) {
38
+ if (err && err.code === 'ENOENT') { // 文件不存在:正常的初始状态
39
+ cacheBox.mtime = 0; cacheBox.list = null;
40
+ return [];
41
+ }
42
+ // 文件存在但解析失败:备份损坏现场,进入只读保护
43
+ try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* ignore */ }
44
+ corrupted.add(file);
45
+ cacheBox.mtime = 0; cacheBox.list = null;
46
+ console.error(`[cards] 数据文件损坏已备份并进入保护:${file}`);
47
+ return [];
48
+ }
49
+ };
50
+ }
51
+ const _cardsCache = { mtime: 0, list: null };
52
+ const _cfgCache = { mtime: 0, list: null };
53
+ const _trashCache = { mtime: 0, list: null };
54
+ const readCardsRaw = cachedReader(CARDS_PATH, _cardsCache);
55
+ const readConfigRaw = cachedReader(CARDS_CFG_PATH, _cfgCache);
56
+ const readTrashRaw = cachedReader(TRASH_PATH, _trashCache);
25
57
  function readCards() {
26
- try { return JSON.parse(fs.readFileSync(CARDS_PATH, 'utf8')); } catch { return []; }
58
+ // 返回浅拷贝:调用方(list sort 等)对数组的操作不影响缓存
59
+ const v = readCardsRaw();
60
+ return Array.isArray(v) ? v.slice() : [];
61
+ }
62
+ function invalidate(file) {
63
+ if (file === CARDS_PATH) { _cardsCache.mtime = 0; _cardsCache.list = null; }
64
+ else if (file === CARDS_CFG_PATH) { _cfgCache.mtime = 0; _cfgCache.list = null; }
65
+ else if (file === TRASH_PATH) { _trashCache.mtime = 0; _trashCache.list = null; }
66
+ }
67
+ // 损坏保护下的写入守卫:拒绝覆盖写(保留备份供人工恢复)
68
+ function guardWrite(file) {
69
+ if (corrupted.has(file)) {
70
+ throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${DATA_DIR} 后删除损坏标记文件`);
71
+ }
27
72
  }
28
73
  function writeCards(list) {
29
74
  ensureDir();
75
+ guardWrite(CARDS_PATH);
76
+ invalidate(CARDS_PATH);
30
77
  const tmp = CARDS_PATH + '.tmp';
31
78
  fs.writeFileSync(tmp, JSON.stringify(list, null, 2), 'utf8');
32
79
  fs.renameSync(tmp, CARDS_PATH);
33
80
  }
34
81
  function readConfig() {
35
- try { return JSON.parse(fs.readFileSync(CARDS_CFG_PATH, 'utf8')); } catch { return { workspace: '' }; }
82
+ const v = readConfigRaw();
83
+ return (v && typeof v === 'object' && !Array.isArray(v)) ? v : { workspace: '' };
36
84
  }
37
85
  function writeConfig(cfg) {
38
86
  ensureDir();
87
+ guardWrite(CARDS_CFG_PATH);
88
+ invalidate(CARDS_CFG_PATH);
39
89
  fs.writeFileSync(CARDS_CFG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
40
90
  }
41
91
  function readTrash() {
42
- try { return JSON.parse(fs.readFileSync(TRASH_PATH, 'utf8')); } catch { return []; }
92
+ const v = readTrashRaw();
93
+ return Array.isArray(v) ? v.slice() : [];
43
94
  }
44
95
  function writeTrash(list) {
45
- ensureDir();
96
+ guardWrite(TRASH_PATH);
97
+ invalidate(TRASH_PATH);
46
98
  fs.writeFileSync(TRASH_PATH, JSON.stringify(list, null, 2), 'utf8');
47
99
  }
48
100
  // 启动时清理超过 30 天的垃圾桶快照,并连带清除其日志,避免占用磁盘
@@ -209,6 +261,24 @@ function isEligible(card, all) {
209
261
  return true;
210
262
  }
211
263
 
264
+ // 环检测:把 cardId 的依赖改为 newDeps 后,沿依赖边 DFS 是否能回到 cardId 自身
265
+ // 用于 PUT 写入拦截(间接环也拦截,如 A→B→C→A)
266
+ function wouldCycle(cardId, newDeps) {
267
+ const all = CardStore.list();
268
+ const byId = new Map(all.filter(c => c.id !== cardId).map(c => [c.id, c]));
269
+ const seen = new Set();
270
+ const stack = [...(newDeps || [])];
271
+ while (stack.length) {
272
+ const cur = stack.pop();
273
+ if (cur === cardId) return true;
274
+ if (seen.has(cur)) continue;
275
+ seen.add(cur);
276
+ const c = byId.get(cur);
277
+ if (c) stack.push(...(c.dependsOn || []));
278
+ }
279
+ return false;
280
+ }
281
+
212
282
  function pickEligible(all, activeIds) {
213
283
  const eligible = all.filter(c => isEligible(c, all) && !activeIds.has(c.id));
214
284
  eligible.sort((a, b) => {
@@ -228,10 +298,16 @@ class CardRunner {
228
298
  this.timer = null;
229
299
  this.procs = new Map(); // cardId -> { pid, child, lastActive, status }
230
300
  this.followups = new Set(); // 追加聊天中的卡牌(并发防护)
301
+ this.killedCards = new Set(); // 本轮编排中被单卡停止的卡:跳过调度直到本轮结束
231
302
  this.baseline = null; // 本轮编排开始时的 done/failed 基线(all_done 报增量)
232
303
  }
233
304
  isRunning() { return this.running; }
234
305
 
306
+ // 并行度热更新:调大后立即补齐在跑任务(无需等下一个任务完成触发 tick)
307
+ onConfigChanged() {
308
+ if (this.running) this.tick();
309
+ }
310
+
235
311
  // 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
236
312
  maxP() {
237
313
  try {
@@ -241,7 +317,7 @@ class CardRunner {
241
317
  return MAX_PARALLEL;
242
318
  }
243
319
 
244
- // 终止单个任务对应的子进程
320
+ // 终止单个任务对应的子进程(删除卡等场景:不改变调度语义)
245
321
  killCard(cardId) {
246
322
  this.active.delete(cardId);
247
323
  const p = this.procs.get(cardId);
@@ -249,6 +325,21 @@ class CardRunner {
249
325
  this.procs.delete(cardId);
250
326
  }
251
327
 
328
+ // 单卡停止:杀进程 + 标记本轮不再自动调度(状态回待执行,与全局停止的复位语义一致)
329
+ stopOne(cardId) {
330
+ const card = CardStore.get(cardId);
331
+ if (!card || card.status !== 'running') return false;
332
+ if (this.followups.has(cardId)) return false; // 追加聊天进行中:走 chatFollowup 自己的错误路径
333
+ this.killedCards.add(cardId);
334
+ this.killCard(cardId);
335
+ // 主动复位状态:以这里为准(子进程 close 回调到达时 runCard 会再写一次 pending,幂等),
336
+ // 避免回调异常丢失时卡片永远停留在 running
337
+ CardStore.update(cardId, { status: 'pending', result: '', error: '', finishedAt: Date.now() });
338
+ broadcast({ type: 'notice', content: `⏹ 已停止任务「${card.title}」,该任务回到待执行(本轮编排不再自动调度它)` });
339
+ broadcast({ type: 'task_done', cardId, status: 'stopped', title: card.title });
340
+ return true;
341
+ }
342
+
252
343
  // 供前端展示:当前存活进程 + opencode 是否在工作中(近 5s 有活动即视为工作中)
253
344
  getProcesses() {
254
345
  const out = [];
@@ -262,6 +353,7 @@ class CardRunner {
262
353
  stop() {
263
354
  this.token++;
264
355
  this.running = false;
356
+ this.killedCards.clear();
265
357
  if (this.timer) { try { this.timer.unref(); } catch { /* ignore */ } }
266
358
  try { stopScope('solo'); } catch { /* ignore */ }
267
359
  this.procs.clear();
@@ -273,6 +365,7 @@ class CardRunner {
273
365
  if (this.running) return;
274
366
  this.running = true;
275
367
  this.token++;
368
+ this.killedCards.clear();
276
369
  // 记录基线:all_done 时报告本轮增量(成功/失败数),避免混入历史任务
277
370
  const all0 = CardStore.list();
278
371
  this.baseline = { done: all0.filter(c => c.status === 'done').length, failed: all0.filter(c => c.status === 'failed').length };
@@ -284,7 +377,8 @@ class CardRunner {
284
377
  if (!this.running) return;
285
378
  const myToken = this.token;
286
379
  const all = CardStore.list();
287
- const eligible = pickEligible(all, this.active);
380
+ // 单卡停止的任务本轮跳过(用户已明确表示停它,不让调度器立刻拉起)
381
+ const eligible = pickEligible(all, this.active).filter(c => !this.killedCards.has(c.id));
288
382
  if (!eligible.length) {
289
383
  if (this.active.size === 0) {
290
384
  this.running = false;
@@ -331,6 +425,12 @@ class CardRunner {
331
425
  if (card.mode === 'continue' && card.chainId) {
332
426
  const prev = CardStore.get(card.chainId);
333
427
  ocSessionId = prev && prev.ocSessionId ? prev.ocSessionId : '';
428
+ // 语义降级显式提示:避免「名义续聊、实际新会话」静默发生
429
+ if (!ocSessionId) {
430
+ const warn = '续聊链首无可用会话(可能已被重置,或由不支持会话续聊的内核执行),本任务将以全新会话执行';
431
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'system', taskId: card.id, content: `[系统提示] ${warn}` });
432
+ broadcast({ type: 'notice', content: `⚠ ${card.title}:${warn}` });
433
+ }
334
434
  }
335
435
 
336
436
  CardStore.update(card.id, { status: 'running', startedAt: Date.now(), error: '', result: '' });
@@ -399,16 +499,18 @@ class CardRunner {
399
499
 
400
500
  const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
401
501
  const stopped = myToken !== this.token;
502
+ const cardStopped = this.killedCards.has(card.id); // 单卡停止:与全局停止同样复位为待执行
402
503
  if (finalText) {
403
504
  store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'work', taskId: card.id, content: finalText.slice(0, 20000) });
404
505
  }
405
- const status = stopped ? 'pending' : (doneError ? 'failed' : 'done');
506
+ const stoppedAny = stopped || cardStopped;
507
+ const status = stoppedAny ? 'pending' : (doneError ? 'failed' : 'done');
406
508
  CardStore.update(card.id, {
407
509
  status,
408
510
  ocSessionId: sesId,
409
511
  // 手动停止回到待执行:清掉残留,避免 pending 卡带着脏结果/错误
410
- result: stopped ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
411
- error: stopped ? '' : (doneError || ''),
512
+ result: stoppedAny ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
513
+ error: stoppedAny ? '' : (doneError || ''),
412
514
  finishedAt: Date.now()
413
515
  });
414
516
  broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
@@ -471,7 +573,8 @@ class CardRunner {
471
573
  model: card.model || '',
472
574
  ocSessionId: sesId,
473
575
  behavior: 'card',
474
- cwd
576
+ cwd,
577
+ scope: 'card-fu' // 独立进程域:停止编排不牵连追加聊天
475
578
  }, (ev) => {
476
579
  const proc = this.procs.get(cardId);
477
580
  if (proc) proc.lastActive = Date.now();
@@ -521,12 +624,16 @@ class CardRunner {
521
624
  }
522
625
 
523
626
  isFollowupRunning(cardId) { return !!this.followups && this.followups.has(cardId); }
627
+ getFollowupIds() { return [...this.followups]; }
524
628
  }
525
629
 
630
+ // 数据文件损坏状态(供 API 告警展示)
631
+ function getCorruptedFiles() { return [...corrupted]; }
632
+
526
633
  function isValidDir(p) {
527
634
  try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
528
635
  }
529
636
 
530
637
  const runner = new CardRunner();
531
638
 
532
- module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH };
639
+ module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, wouldCycle, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH, getCorruptedFiles };
package/app/lib/oc.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // - 首个 sessionID 事件回填 store,实现同一网页会话跨轮次续聊
5
5
  // - 非 opencode 内核(claude/codex/pi)无 -s 续聊能力:单聊退化为一次性对话,每轮全新上下文
6
6
  // - 演示模式(AGENTS_CHAT_MOCK=1)走 mock 子进程,输出模拟为快照事件
7
+ const fs = require('fs');
7
8
  const path = require('path');
8
9
  const { spawn, execFileSync } = require('child_process');
9
10
  const { registerChild, describeTool, resolveCwd } = require('./agent');
@@ -98,7 +99,7 @@ function parseSoloEventLine(line, onEvent, state) {
98
99
 
99
100
  // ---------- 通用子进程运行器 ----------
100
101
  // runnerSpec: { cmd, shell, json, cwd };json=false 时把输出行累积为快照(演示模式用)
101
- function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish) {
102
+ function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish, scope) {
102
103
  let child;
103
104
  try {
104
105
  child = spawn(runnerSpec.cmd, args, {
@@ -112,7 +113,7 @@ function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish) {
112
113
  finish(`启动失败:${error.message}`);
113
114
  return null;
114
115
  }
115
- registerChild(child, 'solo'); // 停止/退出清理复用 agent.js 的登记表(scope='solo')
116
+ registerChild(child, scope || 'solo'); // 停止/退出清理复用 agent.js 的登记表(scope 可区分编排与追加聊天)
116
117
 
117
118
  child.stdin.on('error', () => { /* stdin 已关闭则忽略 */ });
118
119
  if (prompt) child.stdin.write(prompt);
@@ -190,6 +191,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
190
191
  const prompt = String(opts.prompt || '');
191
192
  const model = String(opts.model || '');
192
193
  const ocSessionId = String(opts.ocSessionId || '');
194
+ // 进程归属 scope:停止编排(stopScope('solo'))只杀编排任务,追加聊天用独立 scope 免受牵连
195
+ const scope = String(opts.scope || 'solo');
193
196
  // 工作区:指定后 Agent 在该目录读写文件(卡牌可选 workspace)
194
197
  const cwd = opts.cwd && fs.existsSync(opts.cwd) && fs.statSync(opts.cwd).isDirectory() ? opts.cwd : '';
195
198
  const cwdEnv = cwd ? { ...process.env, AGENTS_CHAT_CWD: cwd } : process.env;
@@ -206,7 +209,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
206
209
  // 演示模式的会话 ID:续聊时复用传入 ID(打通链路),新任务本地生成(无真实续聊)
207
210
  onEvent({ type: 'session', ocSessionId: ocSessionId || 'ses_demo-' + Date.now().toString(36) });
208
211
  onEvent({ type: 'done', error });
209
- }
212
+ },
213
+ scope
210
214
  );
211
215
  }
212
216
 
@@ -222,7 +226,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
222
226
  { cmd: runner.cmd, shell: runner.shell, json: true, cwd: cwd || resolveCwd() },
223
227
  args, prompt, cwdEnv,
224
228
  onEvent,
225
- (error) => onEvent({ type: 'done', error })
229
+ (error) => onEvent({ type: 'done', error }),
230
+ scope
226
231
  );
227
232
  }
228
233
 
@@ -148,6 +148,25 @@
148
148
  .trash-item .ti-info{flex:1;min-width:0}
149
149
  .trash-item .ti-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
150
150
  .trash-item .ti-sub{font-size:11px;color:var(--dim)}
151
+ /* 内核状态横幅(缺失/演示模式/数据保护时显示) */
152
+ .kernel-banner{display:none;gap:8px;align-items:center;padding:8px 18px;font-size:12.5px;line-height:1.6;border-bottom:1px solid var(--line)}
153
+ .kernel-banner.show{display:flex}
154
+ .kernel-banner.kb-missing{background:#fdeceb;color:#b03a31}
155
+ .kernel-banner.kb-demo{background:#fdf6ec;color:#8a5a00}
156
+ .kernel-banner.kb-corrupt{background:#fdeceb;color:#b03a31}
157
+ /* 内核徽标(正常时显示在状态胶囊旁) */
158
+ .kernel-chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dim);background:#eef0f3;border:1px solid var(--line);border-radius:6px;padding:2px 7px}
159
+ /* 执行中卡的运行时长徽标 */
160
+ .badge.run-elapsed{background:#eef7ff;color:var(--acc2);border-color:#c4e2ff;font-variant-numeric:tabular-nums}
161
+ /* 追加聊天进行中徽标 */
162
+ .badge.fu-live{background:#f1e9ff;color:#8a6fe8;border-color:#ddd0ff}
163
+ /* 单卡停止按钮 */
164
+ .icon-btn.danger:hover{color:var(--err);border-color:var(--err)}
165
+ /* 详情弹窗:任务内容块 */
166
+ .detail-content{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:12px;margin:2px 0 10px;white-space:pre-wrap;word-break:break-word;max-height:18vh;overflow:auto;line-height:1.6;font-size:13px}
167
+ /* 空看板引导 */
168
+ .board-guide{grid-column:1/-1;text-align:center;padding:40px 10px;color:var(--dim)}
169
+ .board-guide .bg-actions{display:flex;gap:10px;justify-content:center;margin-top:14px}
151
170
  </style>
152
171
  </head>
153
172
  <body>
@@ -155,6 +174,7 @@
155
174
  <a class="backlink" href="/" title="返回 Agents Chat 群聊">← 群聊</a>
156
175
  <h1>🗂️ 多任务编排</h1>
157
176
  <span class="status-pill" id="statusPill">空闲</span>
177
+ <span class="kernel-chip" id="kernelChip" style="display:none"></span>
158
178
  <span class="sp"></span>
159
179
  <div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
160
180
  📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
@@ -167,11 +187,13 @@
167
187
  </select>
168
188
  </div>
169
189
  <button class="btn" id="btnNew">+ 新建任务</button>
170
- <button class="btn primary" id="btnRun">▶ 开始编排</button>
171
- <button class="btn danger" id="btnStop" disabled>⏹ 停止</button>
190
+ <button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
191
+ <button class="btn danger" id="btnStop" disabled title="停止编排:进行中的任务复位为待执行(不影响追加聊天),未开始的任务保留">⏹ 停止</button>
172
192
  <button class="btn" id="btnClear">清空</button>
173
193
  </header>
174
194
 
195
+ <div class="kernel-banner" id="kernelBanner"></div>
196
+
175
197
  <div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
176
198
 
177
199
  <main>
@@ -209,7 +231,7 @@
209
231
  <label>任务内容(描述,支持 Markdown)</label>
210
232
  <textarea id="fContent" placeholder="把要做的事详细写在这里,Agent 会据此执行"></textarea>
211
233
  <div class="row">
212
- <div><label>优先级(数字越小越优先)</label><input id="fPriority" type="number" value="999" /></div>
234
+ <div><label>优先级(数字越小越优先;拖拽看板卡会按新顺序重写优先级)</label><input id="fPriority" type="number" value="999" /></div>
213
235
  <div><label>执行模式</label>
214
236
  <select id="fMode">
215
237
  <option value="new">新进程(new)</option>
@@ -237,6 +259,8 @@
237
259
  <h3 id="dTitle">任务详情</h3>
238
260
  <div id="dMeta" class="meta" style="margin-bottom:10px"></div>
239
261
  <div class="dep-tag" id="dDeps"></div>
262
+ <label>任务内容</label>
263
+ <div class="detail-content" id="dContent"><span class="empty">(无任务内容)</span></div>
240
264
  <div class="detail-err" id="dError" style="display:none"></div>
241
265
  <label>执行过程(实时)</label>
242
266
  <div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
@@ -280,24 +304,64 @@ const $ = s => document.querySelector(s);
280
304
  const api = async (url, opt) => { const r = await fetch(url, opt); return r.json(); };
281
305
  const post = (url, body) => api(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
282
306
  function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
283
- function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
307
+ function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
284
308
 
285
309
  let CARDS = [];
286
310
  let OPEN_ID = null;
311
+ let FOLLOWUP_IDS = []; // 追加聊天进行中的卡 id
312
+ let RUNNER_INFO = null; // { kind, label }
313
+ let CORRUPTED = []; // 已进入损坏保护的数据文件
314
+ let LOAD_SEQ = 0; // loadCards 响应乱序守卫:快速触发多次时只认最新请求
287
315
 
288
316
  async function loadCards(){
317
+ const seq = ++LOAD_SEQ;
289
318
  const data = await api('/api/cards');
319
+ if (seq !== LOAD_SEQ) return; // 已有更新的请求发出:丢弃本次过期响应
290
320
  CARDS = data.cards || [];
321
+ FOLLOWUP_IDS = data.followupIds || [];
322
+ RUNNER_INFO = data.runner || null;
323
+ CORRUPTED = data.corrupted || [];
291
324
  $('#statusPill').textContent = data.running ? '编排中…' : '空闲';
292
325
  $('#btnRun').disabled = data.running;
293
326
  $('#btnStop').disabled = !data.running;
294
327
  if(data.config) $('#wsInput').value = data.config.workspace || '';
295
328
  if(data.maxParallel) $('#parallelSel').value = String(data.maxParallel);
329
+ renderKernelBanner();
296
330
  render();
297
331
  renderGraph();
298
332
  loadTrashCount();
299
333
  }
300
334
 
335
+ // 内核状态展示:正常=头部小徽标;缺失/演示模式/数据保护=醒目横幅
336
+ function renderKernelBanner(){
337
+ const chip=$('#kernelChip'), banner=$('#kernelBanner');
338
+ const k = RUNNER_INFO && RUNNER_INFO.kind;
339
+ if (CORRUPTED.length){
340
+ chip.style.display='none';
341
+ banner.className='kernel-banner show kb-corrupt';
342
+ banner.innerHTML='⚠ <b>数据保护已触发</b>:'+CORRUPTED.map(f=>'<code>'+esc(f.split(/[\\/]/).pop())+'</code>').join('、')+' 解析失败(原文件已备份为 .corrupt-*),为防数据丢失写入已暂停。请检查数据目录后恢复备份文件。';
343
+ return;
344
+ }
345
+ if (k === 'missing'){
346
+ chip.style.display='none';
347
+ banner.className='kernel-banner show kb-missing';
348
+ banner.innerHTML='❌ <b>未检测到执行内核</b>:任务无法真实执行。请安装 opencode(推荐):终端运行 <code>npm install -g opencode-ai</code>,安装并登录后刷新本页。';
349
+ return;
350
+ }
351
+ if (k === 'demo'){
352
+ chip.style.display='none';
353
+ banner.className='kernel-banner show kb-demo';
354
+ banner.innerHTML='⚠ <b>演示模式</b>(AGENTS_CHAT_MOCK=1):输出均为模拟结果,任务执行不可用。删除 .env 中该配置并安装内核后即为真实执行。';
355
+ return;
356
+ }
357
+ banner.className='kernel-banner';
358
+ if (RUNNER_INFO && RUNNER_INFO.label){
359
+ chip.style.display='inline-flex';
360
+ chip.textContent='⚡ '+RUNNER_INFO.label;
361
+ chip.title='当前执行内核:'+RUNNER_INFO.label;
362
+ }
363
+ }
364
+
301
365
  function badge(card){
302
366
  let p = card.priority;
303
367
  let pc = p<=100?'prio-high':(p<=500?'prio-mid':'');
@@ -310,10 +374,20 @@ function errSummary(err){
310
374
  if(!err) return '';
311
375
  return String(err).split('\n').map(s=>s.trim()).filter(Boolean)[0] || '';
312
376
  }
313
- // 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞)
377
+ // 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞);依赖链成环时明确提示
314
378
  function blockedInfo(card){
315
379
  if(card.status!=='pending' || !(card.dependsOn&&card.dependsOn.length)) return null;
316
380
  const byId=Object.fromEntries(CARDS.map(c=>[c.id,c]));
381
+ // 环检测:沿依赖边 DFS,回到自身即成环(历史数据兜底,正常写入已被服务端拦截)
382
+ const seen=new Set(), stack=[...card.dependsOn];
383
+ while(stack.length){
384
+ const cur=stack.pop();
385
+ if(cur===card.id) return {kind:'fail',text:'循环依赖:依赖链最终依赖本任务,全量执行时将被跳过'};
386
+ if(seen.has(cur)) continue;
387
+ seen.add(cur);
388
+ const c=byId[cur];
389
+ if(c) stack.push(...(c.dependsOn||[]));
390
+ }
317
391
  const failedDeps=[], waitDeps=[];
318
392
  for(const d of card.dependsOn){
319
393
  const dc=byId[d];
@@ -336,11 +410,16 @@ function cardEl(card){
336
410
  const blkHtml = blk ? `<div class="card-blocked${blk.kind==='wait'?' wait':''}">${blk.kind==='fail'?'⛔':'⏳'} ${esc(blk.text)}</div>` : '';
337
411
  const resetHtml = (card.status==='pending' && /服务重启/.test(card.error||''))
338
412
  ? `<div class="card-reset">↻ ${esc(errSummary(card.error))}</div>` : '';
413
+ // 执行中:实时运行时长徽标(每秒刷新);追加聊天中:紫色徽标
414
+ const elapsedHtml = (card.status==='running' && card.startedAt)
415
+ ? `<span class="badge run-elapsed" data-start="${card.startedAt}">⏱ ${fmtElapsed(Date.now()-card.startedAt)}</span>` : '';
416
+ const fuHtml = FOLLOWUP_IDS.includes(card.id) ? '<span class="badge fu-live">💬 回复中</span>' : '';
339
417
  el.innerHTML = `<div class="title">${esc(card.title)}</div>
340
- <div class="meta">${badge(card)} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
418
+ <div class="meta">${badge(card)} ${elapsedHtml} ${fuHtml} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
341
419
  ${errHtml}${blkHtml}${resetHtml}
342
420
  <div class="op">
343
- <button class="icon-btn" data-act="run" title="执行">▶</button>
421
+ ${card.status==='running'?'<button class="icon-btn danger" data-act="stop" title="停止该任务(复位为待执行,本轮编排不再自动调度)">⏹</button>':''}
422
+ <button class="icon-btn" data-act="run" title="执行${card.status==='pending'?'(若依赖未完成将先确认)':'(旧结果先归档)'}">▶</button>
344
423
  <button class="icon-btn" data-act="reset" title="重置(清除结果回到待执行)">⟲</button>
345
424
  <button class="icon-btn" data-act="copy" title="复制为新任务">⧉</button>
346
425
  <button class="icon-btn" data-act="edit" title="编辑">✎</button>
@@ -350,6 +429,7 @@ function cardEl(card){
350
429
  if(a==='del') delCard(card.id);
351
430
  else if(a==='edit') openEdit(card);
352
431
  else if(a==='run') runOne(card.id);
432
+ else if(a==='stop') stopOne(card.id);
353
433
  else if(a==='reset') resetCard(card.id);
354
434
  else if(a==='copy') copyCard(card);
355
435
  return; }
@@ -361,6 +441,20 @@ function cardEl(card){
361
441
  el.addEventListener('drop', e=>{ e.preventDefault(); el.classList.remove('drag-over'); onDrop(card.id, el); });
362
442
  return el;
363
443
  }
444
+ // 运行时长格式化:ms -> "1:05" / "12:03:44"
445
+ function fmtElapsed(ms){
446
+ const s = Math.max(0, Math.floor(ms/1000));
447
+ const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
448
+ const p2 = n => String(n).padStart(2,'0');
449
+ return h ? `${h}:${p2(m)}:${p2(sec)}` : `${m}:${p2(sec)}`;
450
+ }
451
+ // 每秒刷新执行中卡片的运行时长(不重建 DOM,只更新徽标文本)
452
+ setInterval(()=>{
453
+ document.querySelectorAll('.badge.run-elapsed').forEach(b=>{
454
+ const st = Number(b.dataset.start);
455
+ if (st) b.textContent = '⏱ ' + fmtElapsed(Date.now()-st);
456
+ });
457
+ }, 1000);
364
458
  let dragId=null;
365
459
  function onDrop(targetId, targetEl){
366
460
  if(!dragId || dragId===targetId) return;
@@ -384,9 +478,41 @@ function render(){
384
478
  for(const c of CARDS){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
385
479
  $('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
386
480
  $('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
481
+ // 空看板引导:一个任务都没有时给出上手入口(含示例)
482
+ if(!CARDS.length){
483
+ cols.pending.innerHTML=`<div class="board-guide">
484
+ 还没有任何任务<br/>把要做的事写成一张张卡牌,Agent 会按依赖与优先级自动调度执行
485
+ <div class="bg-actions">
486
+ <button class="btn primary" onclick="openEdit(null)">+ 新建第一个任务</button>
487
+ <button class="btn" id="btnExamples">📦 载入示例任务</button>
488
+ </div></div>`;
489
+ const be=$('#btnExamples'); if(be) be.onclick=loadExamples;
490
+ for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
491
+ return;
492
+ }
387
493
  for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
388
494
  }
389
495
 
496
+ // 载入示例:三张卡展示三种执行模式与依赖关系(可编辑/删除,当作模板随意改造)
497
+ async function loadExamples(){
498
+ const mk = (title, content, extra) => post('/api/cards', Object.assign({ title, content }, extra||{})).then(r=>r.card);
499
+ const a = await mk('示例A · 独立任务', '列出当前工作目录下的全部文件,并统计各类型文件的数量。', { priority: 1 });
500
+ await mk('示例B · 续聊任务', '接续上一个任务的结论:把统计结果整理成一份 markdown 表格。', { priority: 2, mode: 'continue', chainId: a.id, dependsOn: [a.id] });
501
+ await mk('示例C · 并行任务', '查询今日天气概况并给出一句话总结(与示例A/B 互不依赖,可同时执行)。', { priority: 3, mode: 'parallel' });
502
+ await loadCards();
503
+ toast('已载入 3 个示例任务,点击「开始编排」体验');
504
+ }
505
+
506
+ // 单卡停止:执行中的任务杀进程并复位为待执行(本轮编排不再自动调度)
507
+ async function stopOne(id){
508
+ const c=CARDS.find(x=>x.id===id);
509
+ if(!c || c.status!=='running') return;
510
+ if(!confirm(`停止任务「${c.title}」?该任务将复位为待执行,本轮编排不再自动调度它。`)) return;
511
+ const r=await post('/api/cards/'+id+'/stop');
512
+ if(!r.success) toast(r.error||'停止失败');
513
+ await loadCards();
514
+ }
515
+
390
516
  // ---- 编辑/新建 ----
391
517
  // 候选列表排除自身:禁止把自己选为依赖/链首(防自环)
392
518
  function fillSelectors(){
@@ -448,7 +574,11 @@ $('#btnEditSave').onclick=async()=>{
448
574
  dependsOn:deps };
449
575
  if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
450
576
  else await post('/api/cards',body);
451
- $('#maskEdit').classList.remove('show'); await loadCards(); toast('已保存');
577
+ $('#maskEdit').classList.remove('show');
578
+ await loadCards();
579
+ // 编排状态联动提示:编排进行中=新卡自动加入调度;空闲=明确告知需手动开始
580
+ if(!EDIT_ID) toast($('#btnRun').disabled ? '已保存,新任务将自动加入本轮调度' : '已保存。当前编排空闲,点「▶ 开始编排」执行');
581
+ else toast('已保存');
452
582
  };
453
583
  async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾桶(30 天内可还原)。'))return; await api('/api/cards/'+id,{method:'DELETE'}); await loadCards(); toast('已移入垃圾桶'); }
454
584
  $('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
@@ -471,6 +601,13 @@ $('#parallelSel').onchange=async()=>{
471
601
  $('#dExport').onclick=()=>{ if(!OPEN_ID)return; window.open('/api/cards/'+encodeURIComponent(OPEN_ID)+'/export.md','_blank'); };
472
602
  async function runOne(id){
473
603
  const c=CARDS.find(x=>x.id===id);
604
+ // 依赖未完成时显式确认:让「立即执行会绕过依赖」这件事说清楚
605
+ if(c && c.status==='pending' && c.dependsOn && c.dependsOn.length){
606
+ const undone = c.dependsOn.filter(d=>{ const dc=CARDS.find(x=>x.id===d); return !dc || dc.status!=='done'; });
607
+ if(undone.length){
608
+ if(!confirm(`该任务还有 ${undone.length} 个依赖未完成(调度器会等它们完成后才自动执行)。\n立即执行将忽略依赖直接运行,确认?`)) return;
609
+ }
610
+ }
474
611
  // 重跑保护:已有结果时先确认(旧结果会归档进过程日志)
475
612
  if(c && (c.status==='done'||c.status==='failed') && c.result){
476
613
  if(!confirm('重新执行将把上次结果归档到过程日志,然后开始新一轮执行。确认?')) return;
@@ -538,6 +675,10 @@ async function openDetail(id){
538
675
  $('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
539
676
  const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
540
677
  $('#dDeps').textContent = depTitles.length ? ('依赖:'+depTitles.join('、')) : '';
678
+ // 任务内容:详情首先呈现「这个任务要求做什么」,不必再点编辑查看
679
+ const cbox=$('#dContent');
680
+ if(card.content) cbox.textContent=card.content;
681
+ else cbox.innerHTML='<span class="empty">(无任务内容)</span>';
541
682
  // 失败原因块:任务执行错误优先,其次追加聊天的错误
542
683
  const errBox=$('#dError');
543
684
  const errText = card.error || card.followupError || '';
@@ -728,7 +869,8 @@ async function refreshProcs(){
728
869
  const d = await api('/api/cards/processes');
729
870
  const procs = d.processes||[];
730
871
  const bar=$('#procBar');
731
- if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span>'; return; }
872
+ const kname = (RUNNER_INFO && RUNNER_INFO.label) || 'opencode';
873
+ if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 '+esc(kname)+' 进程</span>'; return; }
732
874
  bar.innerHTML='<span class="ptitle">进程:</span>'+procs.map(p=>{
733
875
  const working=p.working;
734
876
  return `<span class="proc-chip ${working?'':'idle'}"><span class="dot"></span><span class="pid">PID ${p.pid}</span><span class="st">${working?'工作中':'空闲'}</span></span>`;
@@ -763,8 +905,17 @@ function notifyDone(done,failed){
763
905
  }catch{ /* ignore */ }
764
906
  }
765
907
  const es = new EventSource('/api/cards/stream');
908
+ // 断线重连后补拉:错过的状态迁移事件靠全量刷新弥补(init 只恢复按钮态)
909
+ es.onopen = ()=>{ loadCards(); refreshProcs(); };
766
910
  es.onmessage = (e)=>{
767
911
  let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
912
+ if(ev.type==='init'){ // 连接建立时的初始状态(含断线重连场景)
913
+ $('#statusPill').textContent = ev.running ? '编排中…' : '空闲';
914
+ $('#btnRun').disabled = !!ev.running; $('#btnStop').disabled = !ev.running;
915
+ if(ev.maxParallel) $('#parallelSel').value = String(ev.maxParallel);
916
+ return;
917
+ }
918
+ if(ev.type==='notice'){ toast(ev.content); }
768
919
  if(ev.type==='runner_started'){ $('#statusPill').textContent='编排中…'; $('#btnRun').disabled=true; $('#btnStop').disabled=false; }
769
920
  if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
770
921
  if(ev.type==='all_done'){
@@ -773,17 +924,25 @@ es.onmessage = (e)=>{
773
924
  }
774
925
  if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
775
926
  if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
776
- // text 为正文快照(不改变卡片状态):仅实时渲染,不触发全量刷新,避免流式期间频繁重建 DOM
777
- if(['task_start','tool','task_done','proc'].includes(ev.type)){
927
+ // 状态迁移事件:才触发全量刷新(text/tool 快照不改变卡片状态)
928
+ if(['task_start','task_done'].includes(ev.type)){
778
929
  loadCards();
779
930
  refreshProcs();
931
+ // 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
932
+ if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
780
933
  if(OPEN_ID && ev.cardId===OPEN_ID){
781
934
  const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
782
935
  if(ev.type==='task_start') TEXT_PARTS.clear();
783
- else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
784
936
  else if(ev.type==='task_done'){ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); }); }
785
937
  }
786
938
  }
939
+ // proc 仅刷新进程条(另有 2s 轮询兜底),不重建看板 DOM
940
+ if(ev.type==='proc'){ refreshProcs(); }
941
+ // tool 事件仅在打开对应详情时追加过程日志,避免工具密集时高频全量刷新
942
+ if(ev.type==='tool' && OPEN_ID===ev.cardId){
943
+ const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
944
+ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight;
945
+ }
787
946
  // 正文快照独立渲染(不依赖上面的状态事件)
788
947
  if(ev.type==='text' && OPEN_ID===ev.cardId) renderSnapText(ev);
789
948
  if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
@@ -634,7 +634,7 @@
634
634
  </div>
635
635
 
636
636
  <script>
637
- const PAGE_VERSION = '3.19.2'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
637
+ const PAGE_VERSION = '3.20.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
638
638
  const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
639
639
  const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
640
640
  // 职业图标库(智能体配置可选)
@@ -2319,7 +2319,6 @@ async function doImport() {
2319
2319
  (data.warnings || []).forEach(w => chatMode === 'solo' ? addSoloSys('⚠ ' + w) : addSys('⚠ ' + w));
2320
2320
  closeImport();
2321
2321
  importText.value = '';
2322
- if (importMode === 'scheduled' && schedOpen) renderSchedList();
2323
2322
  } else toast(data.error || '导入失败');
2324
2323
  }
2325
2324
 
package/app/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Agents Chat Portable - 零依赖 HTTP 服务
2
2
  // 启动:node app/server.js [--port 3456]
3
- const APP_VERSION = '3.19.2'; // 页面与服务端版本互检,不一致提示强刷
3
+ const APP_VERSION = '3.20.0'; // 页面与服务端版本互检,不一致提示强刷
4
4
  const http = require('http');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
@@ -38,7 +38,7 @@ const { runAgent, stopScope, stopAllChildren } = require('./lib/agent');
38
38
  const { runButler, runMentioned, runRoundtable, runTasks, prepareRerun } = require('./lib/orchestrator');
39
39
  const oc = require('./lib/oc');
40
40
  const memoryMod = require('./lib/memory');
41
- const { CardStore, runner: cardRunner, sseSubscribe, MAX_PARALLEL } = require('./lib/cards');
41
+ const { CardStore, runner: cardRunner, sseSubscribe, MAX_PARALLEL, wouldCycle } = require('./lib/cards');
42
42
 
43
43
  // ---------- 人工审批关卡:orchestrator 暂停等待用户放行(方案/交付),SSE 断线后可经 /api/approvals 恢复 ----------
44
44
  const pendingApprovals = new Map(); // approvalId -> {kind,label,taskId,resolve,timer}
@@ -948,7 +948,18 @@ const server = http.createServer(async (req, res) => {
948
948
  try {
949
949
  for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
950
950
  } catch { /* ignore */ }
951
- json(res, 200, { success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig() });
951
+ // 执行内核状态(前端据此显示内核徽标/缺失警告)与追加聊天进行中的卡
952
+ let runnerInfo = { kind: 'unknown', label: '' };
953
+ try {
954
+ const { resolveRunner } = require('./lib/agent');
955
+ const r = resolveRunner();
956
+ runnerInfo = { kind: r.kind, label: r.kernel ? r.kernel.label : '' };
957
+ } catch { /* ignore */ }
958
+ const { getCorruptedFiles } = require('./lib/cards');
959
+ json(res, 200, {
960
+ success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig(),
961
+ followupIds: cardRunner.getFollowupIds(), runner: runnerInfo, corrupted: getCorruptedFiles()
962
+ });
952
963
  return;
953
964
  }
954
965
 
@@ -979,6 +990,7 @@ const server = http.createServer(async (req, res) => {
979
990
  }
980
991
  if (parallelBad) { json(res, 400, { success: false, error: 'maxParallel 需在 1-8 之间' }); return; }
981
992
  const cfg = Object.keys(patch).length ? CardStore.setConfig(patch) : CardStore.getConfig();
993
+ if (patch.maxParallel !== undefined) cardRunner.onConfigChanged(); // 并行度调大时立即补齐
982
994
  // 工作区校验:保存允许(可能是尚未创建的目录),但路径不存在时显式警告
983
995
  let warning = '';
984
996
  const ws = (cfg.workspace || '').trim();
@@ -1071,16 +1083,22 @@ const server = http.createServer(async (req, res) => {
1071
1083
  const card = CardStore.get(id);
1072
1084
  if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1073
1085
  const msgs = store.getMessages(id);
1086
+ // 中文友好的日期时间(YYYY-MM-DD HH:mm,避免随系统 locale 变成英文格式)
1087
+ const fmtDateTime = (t) => {
1088
+ const d = new Date(t || Date.now());
1089
+ const p2 = (n) => String(n).padStart(2, '0');
1090
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
1091
+ };
1074
1092
  const roleOf = (m) => (m.role === 'user' ? '用户' : (m.phase === 'archive' ? '归档' : (m.phase === 'system' ? '系统' : 'Agent')));
1075
1093
  const lines = [
1076
1094
  `# ${card.title}`, '',
1077
1095
  `- 状态:${card.status}|模式:${card.mode}|优先级:P${card.priority}`,
1078
- `- 创建:${new Date(card.createdAt).toLocaleString()}${card.finishedAt ? `|完成:${new Date(card.finishedAt).toLocaleString()}` : ''}`,
1096
+ `- 创建:${fmtDateTime(card.createdAt)}${card.finishedAt ? `|完成:${fmtDateTime(card.finishedAt)}` : ''}`,
1079
1097
  card.error ? `- 失败原因:${card.error.replace(/\n/g, ' ')}` : '',
1080
1098
  '', '## 任务内容', '', String(card.content || '').trim(), '', '## 执行过程', ''
1081
1099
  ];
1082
1100
  for (const m of msgs) {
1083
- lines.push(`### ${new Date(m.timestamp || Date.now()).toLocaleString()} · ${roleOf(m)}`, '');
1101
+ lines.push(`### ${fmtDateTime(m.timestamp || Date.now())} · ${roleOf(m)}`, '');
1084
1102
  lines.push(String(m.content || '').trim() || '(无内容)');
1085
1103
  lines.push('');
1086
1104
  }
@@ -1107,13 +1125,24 @@ const server = http.createServer(async (req, res) => {
1107
1125
  if (p.startsWith('/api/cards/') && req.method === 'PUT') {
1108
1126
  const id = p.slice('/api/cards/'.length);
1109
1127
  const body = await readBody(req);
1128
+ // 运行中/追加聊天中的卡禁止编辑与重置:子进程结束时会回写状态,中途改写会产生竞争
1129
+ const target = CardStore.get(id);
1130
+ if (target && (target.status === 'running' || cardRunner.isFollowupRunning(id))) {
1131
+ json(res, 409, { success: false, error: target.status === 'running' ? '任务正在执行中,无法编辑或重置,请等待完成或先停止编排' : '该任务追加聊天进行中,稍后再修改' });
1132
+ return;
1133
+ }
1110
1134
  const patch = {};
1111
1135
  if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
1112
1136
  if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
1113
1137
  if (body.priority !== undefined) patch.priority = Number(body.priority) || 999;
1114
1138
  if (body.mode !== undefined) patch.mode = ['new', 'continue', 'parallel'].includes(body.mode) ? body.mode : 'new';
1115
1139
  if (body.chainId !== undefined) patch.chainId = String(body.chainId).slice(0, 100);
1116
- if (Array.isArray(body.dependsOn)) patch.dependsOn = body.dependsOn.map(String).slice(0, 50);
1140
+ if (Array.isArray(body.dependsOn)) {
1141
+ const deps = body.dependsOn.map(String).slice(0, 50);
1142
+ // 环检测:修改依赖后若形成(直接或间接)循环依赖则拒绝写入
1143
+ if (wouldCycle(id, deps)) { json(res, 409, { success: false, error: '循环依赖:该任务的依赖链最终会依赖它自己,请调整依赖关系' }); return; }
1144
+ patch.dependsOn = deps;
1145
+ }
1117
1146
  if (body.model !== undefined) patch.model = String(body.model).slice(0, 80);
1118
1147
  // 状态手动复位:failed/pending -> pending 可重跑
1119
1148
  if (body.status === 'pending') { patch.status = 'pending'; patch.result = ''; patch.error = ''; patch.ocSessionId = ''; }
@@ -1125,6 +1154,7 @@ const server = http.createServer(async (req, res) => {
1125
1154
 
1126
1155
  if (p.startsWith('/api/cards/') && req.method === 'DELETE') {
1127
1156
  const id = p.slice('/api/cards/'.length);
1157
+ if (!CardStore.get(id)) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1128
1158
  cardRunner.killCard(id);
1129
1159
  CardStore.remove(id);
1130
1160
  json(res, 200, { success: true });
@@ -1138,6 +1168,14 @@ const server = http.createServer(async (req, res) => {
1138
1168
  return;
1139
1169
  }
1140
1170
 
1171
+ // 单卡停止:杀掉该卡子进程,状态复位为待执行(本轮编排不再自动调度它)
1172
+ if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/stop')) {
1173
+ const id = p.slice('/api/cards/'.length, -'/stop'.length);
1174
+ const ok = cardRunner.stopOne(id);
1175
+ json(res, ok ? 200 : 409, { success: ok, error: ok ? '' : '任务不在执行中,无法停止' });
1176
+ return;
1177
+ }
1178
+
1141
1179
  // 追加聊天:任务完成后复用其 opencode 会话续聊(同一进程的第二轮输入)
1142
1180
  if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/chat')) {
1143
1181
  const id = p.slice('/api/cards/'.length, -'/chat'.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.19.2",
3
+ "version": "3.21.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {