@iamsamyiok/agents-chat 3.30.0 → 3.32.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/.env.example ADDED
@@ -0,0 +1,26 @@
1
+ # Agents Chat 配置示例:复制为同目录 .env 后按需修改;全部可省略
2
+
3
+ # ---- 通知 ----
4
+ # 任务/卡牌完成时转发 JSON 到该地址(钉钉/飞书自定义 bot 或自建中转均可):
5
+ # POST {event,kind,title,status,snippet,text},2 秒超时失败静默
6
+ # AGENTS_CHAT_WEBHOOK_URL=https://example.com/your-hook
7
+
8
+ # ---- 资源与清理 ----
9
+ # 每日滚动清理保留天数(默认 15;0 不代表关闭清理,请用大数值)
10
+ # AGENTS_CHAT_PRUNE_DAYS=15
11
+ # 单会话消息滚动上限(默认 500,0=关闭;主会话不受限)
12
+ # AGENTS_CHAT_MSG_LIMIT=500
13
+
14
+ # ---- 自动退出 ----
15
+ # 全部页面关闭且空闲后自动退出(默认开;0 关闭)
16
+ # AGENTS_CHAT_AUTOSTOP=1
17
+ # 空闲多少毫秒后退出(默认 50000)
18
+ # AGENTS_CHAT_AUTOSTOP_IDLE_MS=50000
19
+
20
+ # ---- 内核与执行 ----
21
+ # 自动安装 opencode(默认开;0 关闭)
22
+ # AGENTS_CHAT_AUTO_INSTALL=1
23
+ # 指定内核完整路径(按需)
24
+ # AGENTS_CHAT_OPENCODE_CMD=
25
+ # 单任务超时毫秒(默认 10 分钟)
26
+ # AGENTS_CHAT_TIMEOUT_MS=600000
package/app/lib/agent.js CHANGED
@@ -349,10 +349,13 @@ function runAgent(agent, prompt, onChunk, scope) {
349
349
  }
350
350
 
351
351
  // 智能体工作目录(全局统一):
352
- // - 环境变量 AGENTS_CHAT_CWD 优先(卡牌 workspace 等单次执行场景注入)
352
+ // - 任务隔离上下文优先(worktree 任务执行期间注入,见 lib/worktree.js runWithTaskCwd)
353
+ // - 环境变量 AGENTS_CHAT_CWD 次之(卡牌 workspace 等单次执行场景注入)
353
354
  // - 配置了有效 globalCwd → 所有智能体共用该目录(文件资料集中在一处)
354
355
  // - 否则默认 <数据目录>/workspace/ 共享目录
355
356
  function resolveCwd() {
357
+ const wtCwd = require('./worktree').currentTaskCwd();
358
+ if (wtCwd) return wtCwd;
356
359
  const envCwd = String(process.env.AGENTS_CHAT_CWD || '').trim();
357
360
  if (envCwd) {
358
361
  try { if (fs.existsSync(envCwd) && fs.statSync(envCwd).isDirectory()) return envCwd; } catch { /* ignore */ }
package/app/lib/cards.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { resolveRunner, detectKernels, KERNEL_DEFS, stopScope } = require('./agent');
14
+ const { notifyDone } = require('./notify');
14
15
  const oc = require('./oc');
15
16
  const store = require('./store');
16
17
  const safejson = require('./safejson');
@@ -522,6 +523,7 @@ class CardRunner {
522
523
  finishedAt: Date.now()
523
524
  });
524
525
  broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
526
+ notifyDone({ kind: 'card', title: card.title, status, snippet: doneError || finalText });
525
527
  // 任务结束,移除进程登记
526
528
  this.procs.delete(card.id);
527
529
  }
@@ -0,0 +1,26 @@
1
+ // 完成通知出口:webhook 转发(钉钉/飞书/Telegram bot 的中转地址均可)
2
+ // .env AGENTS_CHAT_WEBHOOK_URL 配置后生效;POST {event,title,status,snippet,text},text 字段可直接被
3
+ // 简单集成消费。2 秒超时失败静默——通知是锦上添花,绝不影响任务流程。
4
+ let warned = false;
5
+
6
+ function notifyDone({ kind = 'task', title = '', status = 'done', snippet = '' } = {}) {
7
+ const hook = process.env.AGENTS_CHAT_WEBHOOK_URL;
8
+ if (!hook) return;
9
+ const icon = status === 'done' ? '✅' : status === 'failed' ? '❌' : '⏹';
10
+ const text = `${icon} Agents Chat ${kind === 'card' ? '卡牌' : '任务'}${status === 'done' ? '完成' : status === 'failed' ? '失败' : '已停止'}:${title}${snippet ? '\n' + snippet.slice(0, 200) : ''}`;
11
+ const body = { event: 'done', kind, title, status, snippet: snippet.slice(0, 500), text };
12
+ const ctrl = new AbortController();
13
+ const timer = setTimeout(() => ctrl.abort(), 2000);
14
+ fetch(hook, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json' },
17
+ body: JSON.stringify(body),
18
+ signal: ctrl.signal
19
+ }).then(() => { warned = false; })
20
+ .catch(() => {
21
+ if (!warned) { warned = true; console.warn('[notify] webhook 发送失败(后续失败静默):' + hook); }
22
+ })
23
+ .finally(() => clearTimeout(timer));
24
+ }
25
+
26
+ module.exports = { notifyDone };
package/app/lib/oc.js CHANGED
@@ -193,9 +193,16 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
193
193
  const ocSessionId = String(opts.ocSessionId || '');
194
194
  // 进程归属 scope:停止编排(stopScope('solo'))只杀编排任务,追加聊天用独立 scope 免受牵连
195
195
  const scope = String(opts.scope || 'solo');
196
- // 工作区:指定后 Agent 在该目录读写文件(卡牌可选 workspace
197
- const cwd = opts.cwd && fs.existsSync(opts.cwd) && fs.statSync(opts.cwd).isDirectory() ? opts.cwd : '';
198
- const cwdEnv = cwd ? { ...process.env, AGENTS_CHAT_CWD: cwd } : process.env;
196
+ // 工作区:指定后 Agent 在该目录读写文件(卡牌可选 workspace;任务隔离 worktree 经 ALS 注入)
197
+ const alsCwd = require('./worktree').currentTaskCwd();
198
+ const cwdInput = opts.cwd || alsCwd;
199
+ const cwd = cwdInput && fs.existsSync(cwdInput) && fs.statSync(cwdInput).isDirectory() ? cwdInput : '';
200
+ // 演示模式按调用场景选择 mock 行为(solo-task 会真实写产出文件,供隔离区 diff 演示)
201
+ const cwdEnv = {
202
+ ...process.env,
203
+ ...(cwd ? { AGENTS_CHAT_CWD: cwd } : {}),
204
+ ...(runnerKind === 'demo' ? { MOCK_BEHAVIOR: String(opts.behavior || 'echo') } : {})
205
+ };
199
206
 
200
207
  // ---- 演示模式:mock 子进程 + 快照模拟 ----
201
208
  if (runnerKind === 'demo') {
@@ -1076,32 +1076,37 @@ async function runTasks(tasks, butler, subAgents, opts, emit, onMessage, onTaskS
1076
1076
  emit({ type: 'notice', content: '已手动停止,剩余任务保持待执行状态' });
1077
1077
  break;
1078
1078
  }
1079
- const prompt = `请完成以下任务并给出结果:\n${task.title}${task.notes ? `\n\n补充说明:${task.notes}` : ''}`;
1080
- // 先建历史背景(不含本任务的起始消息),再写入任务会话首条消息
1081
- const history = opts.getHistory ? opts.getHistory(task.id) : '';
1082
- if (onTaskStart) onTaskStart(task);
1083
- // 该任务产生的全部消息都归入对应任务会话
1084
- const persistTask = (m) => onMessage({ ...m, taskId: task.id });
1085
-
1086
- const assigned = opts.resolveAssign ? opts.resolveAssign(task) : null;
1087
- let r;
1088
- if (assigned && assigned.id !== butler.id) {
1089
- // 指派子智能体:独立完成,无管家编排
1090
- emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: assigned.name });
1091
- emit({ type: 'notice', content: `本任务由 @${assigned.name} 独立完成(无管家调度)`, taskId: task.id });
1092
- r = await runMentioned([assigned], prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
1093
- } else {
1094
- emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: butler.name });
1095
- r = await runButler(butler, subAgents, prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
1096
- }
1097
- let status = r.ok ? 'done' : 'failed';
1098
- let resultText = (r.finalText || '').trim() || '执行失败';
1099
- if (isStopped() && !r.ok) {
1100
- status = 'pending'; // 手动停止的任务回到待执行,可随时重跑
1101
- resultText = '已手动停止,可重新执行';
1102
- }
1103
- onTaskDone(task.id, { status, result: resultText.slice(0, 10000) });
1104
- emit({ type: 'task_done', taskId: task.id, status, title: task.title });
1079
+ // 任务隔离 worktree:opts.taskCwd(task) 返回隔离目录(空 = 共享目录),整任务执行期间注入 cwd 上下文
1080
+ const taskCwd = opts.taskCwd ? String(opts.taskCwd(task) || '') : '';
1081
+ if (taskCwd) emit({ type: 'notice', content: `🌿 本任务在 Git 隔离区执行:${taskCwd}`, taskId: task.id });
1082
+ await require('./worktree').runWithTaskCwd(taskCwd, async () => {
1083
+ const prompt = `请完成以下任务并给出结果:\n${task.title}${task.notes ? `\n\n补充说明:${task.notes}` : ''}`;
1084
+ // 先建历史背景(不含本任务的起始消息),再写入任务会话首条消息
1085
+ const history = opts.getHistory ? opts.getHistory(task.id) : '';
1086
+ if (onTaskStart) onTaskStart(task);
1087
+ // 该任务产生的全部消息都归入对应任务会话
1088
+ const persistTask = (m) => onMessage({ ...m, taskId: task.id });
1089
+
1090
+ const assigned = opts.resolveAssign ? opts.resolveAssign(task) : null;
1091
+ let r;
1092
+ if (assigned && assigned.id !== butler.id) {
1093
+ // 指派子智能体:独立完成,无管家编排
1094
+ emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: assigned.name });
1095
+ emit({ type: 'notice', content: `本任务由 @${assigned.name} 独立完成(无管家调度)`, taskId: task.id });
1096
+ r = await runMentioned([assigned], prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
1097
+ } else {
1098
+ emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: butler.name });
1099
+ r = await runButler(butler, subAgents, prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
1100
+ }
1101
+ let status = r.ok ? 'done' : 'failed';
1102
+ let resultText = (r.finalText || '').trim() || '执行失败';
1103
+ if (isStopped() && !r.ok) {
1104
+ status = 'pending'; // 手动停止的任务回到待执行,可随时重跑
1105
+ resultText = '已手动停止,可重新执行';
1106
+ }
1107
+ onTaskDone(task.id, { status, result: resultText.slice(0, 10000) });
1108
+ emit({ type: 'task_done', taskId: task.id, status, title: task.title });
1109
+ });
1105
1110
  }
1106
1111
  emit({ type: 'all_done' });
1107
1112
  }
package/app/lib/store.js CHANGED
@@ -397,7 +397,7 @@ function importTasks(text, mode, runner, model) {
397
397
  for (const t of tasks) { if (t.seq === undefined) t.seq = next++; else next = Math.max(next, t.seq + 1); }
398
398
  for (const t of parsed) { t.seq = next++; if (runner === 'solo' && model) t.model = String(model); }
399
399
  saveTasks(tasks.concat(parsed));
400
- return { added: parsed.length, warnings };
400
+ return { added: parsed.length, warnings, addedTasks: parsed };
401
401
  }
402
402
 
403
403
  // 拖拽排序:按给定 id 顺序重编 seq(ids 应为全量,未包含的追加在末尾)
@@ -551,6 +551,92 @@ function addMessage(msg) {
551
551
  msgs.push(...kept);
552
552
  }
553
553
  writeJson(msgShardPath(key), msgs);
554
+ // 用量统计:消息携带 usage(token 数)即累计——所有 runner 的消息路径统一经过这里
555
+ const u = Number(msg.usage);
556
+ if (u > 0) recordUsage({ tokens: Math.floor(u), requests: 1 });
557
+ }
558
+
559
+ // ---------- 全历史关键词检索:逐分片匹配,不合并大数组 ----------
560
+ function searchMessages(q, { limit = 50 } = {}) {
561
+ const needle = String(q || '').trim().toLowerCase();
562
+ if (!needle) return [];
563
+ const out = [];
564
+ let files = [];
565
+ try { files = fs.readdirSync(MSG_DIR); } catch { return out; }
566
+ for (const f of files) {
567
+ if (!f.endsWith('.json')) continue;
568
+ let list;
569
+ try { list = readJson(path.join(MSG_DIR, f), []); } catch { continue; }
570
+ if (!Array.isArray(list)) continue;
571
+ // 分片名还原会话标识(hex 编码逆变换,_main 为主会话)
572
+ const stem = f.replace(/\.json$/, '');
573
+ const sessionId = stem === '_main' ? '' : (stem.startsWith('_') ? Buffer.from(stem.slice(1), 'hex').toString('utf8') : stem);
574
+ for (const m of list) {
575
+ const c = String(m.content || '');
576
+ if (c.toLowerCase().includes(needle)) {
577
+ const idx = c.toLowerCase().indexOf(needle);
578
+ const from = Math.max(0, idx - 40);
579
+ out.push({
580
+ sessionId, role: m.role || '', agentName: m.agentName || '',
581
+ snippet: (from > 0 ? '…' : '') + c.slice(from, from + needle.length + 80) + '…',
582
+ timestamp: m.timestamp || ''
583
+ });
584
+ if (out.length >= limit * 3) break; // 分片内粗截,最后统一排序取 limit
585
+ }
586
+ }
587
+ }
588
+ out.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
589
+ return out.slice(0, limit);
590
+ }
591
+
592
+ // ---------- 用量统计(token/请求数按日累计,usage.json) ----------
593
+ const USAGE_PATH = path.join(DATA_DIR, 'usage.json');
594
+ function readUsage() {
595
+ const d = readJson(USAGE_PATH, { days: {}, total: { tokens: 0, requests: 0 } });
596
+ if (!d.days) d.days = {};
597
+ if (!d.total) d.total = { tokens: 0, requests: 0 };
598
+ return d;
599
+ }
600
+ function recordUsage({ tokens = 0, requests = 0 } = {}) {
601
+ const d = readUsage();
602
+ const day = new Date().toISOString().slice(0, 10);
603
+ if (!d.days[day]) d.days[day] = { tokens: 0, requests: 0 };
604
+ d.days[day].tokens += Math.floor(tokens);
605
+ d.days[day].requests += Math.floor(requests);
606
+ d.total.tokens += Math.floor(tokens);
607
+ d.total.requests += Math.floor(requests);
608
+ // 只保留最近 60 天明细,总量恒累计
609
+ const days = Object.keys(d.days).sort();
610
+ while (days.length > 60) delete d.days[days.shift()];
611
+ writeJson(USAGE_PATH, d);
612
+ }
613
+ function getUsageStats() {
614
+ const d = readUsage();
615
+ const today = new Date().toISOString().slice(0, 10);
616
+ const recent = Object.entries(d.days).slice(-7).map(([day, v]) => ({ day, ...v }));
617
+ return { today: d.days[today] || { tokens: 0, requests: 0 }, total: d.total, recent };
618
+ }
619
+
620
+ // ---------- 数据目录占用统计(可见性:让用户看得到空间去哪了) ----------
621
+ function dirSize(p) {
622
+ let total = 0;
623
+ let entries = [];
624
+ try { entries = fs.readdirSync(p, { withFileTypes: true }); } catch { return 0; }
625
+ for (const e of entries) {
626
+ const fp = path.join(p, e.name);
627
+ try {
628
+ if (e.isDirectory()) total += dirSize(fp);
629
+ else total += fs.statSync(fp).size;
630
+ } catch { /* ignore */ }
631
+ }
632
+ return total;
633
+ }
634
+ function dataStats() {
635
+ const parts = {};
636
+ for (const name of ['messages', 'outputs', 'workspace', 'flow']) {
637
+ parts[name] = dirSize(path.join(DATA_DIR, name));
638
+ }
639
+ return { total: dirSize(DATA_DIR), ...parts };
554
640
  }
555
641
 
556
642
  // 按会话统计消息数(逐分片累加,不合并大数组:统计/导出场景低内存)
@@ -795,6 +881,10 @@ module.exports = {
795
881
  addMessage,
796
882
  clearMessages,
797
883
  countMessagesByTask,
884
+ searchMessages,
885
+ recordUsage,
886
+ getUsageStats,
887
+ dataStats,
798
888
  addFlowEvent,
799
889
  getFlow,
800
890
  listFlowRuns,
@@ -0,0 +1,192 @@
1
+ // Git worktree 任务隔离:
2
+ // 创建任务时可选在独立 git worktree 中执行(每任务独立分支+目录,互不污染),
3
+ // 完成后可查看改动 diff、合并回主工作目录或整体丢弃。
4
+ // 工作目录(resolveCwd())不是 git 仓库时功能自动降级(任务照常在共享目录执行)。
5
+ //
6
+ // cwd 传递:AsyncLocalStorage 按任务执行上下文注入(agent.js/oc.js 的 resolveCwd 优先读取),
7
+ // 与并发的聊天请求互不干扰(聊天不在该 async 上下文内)。
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execFile } = require('child_process');
11
+ const { AsyncLocalStorage } = require('node:async_hooks');
12
+ const store = require('./store');
13
+
14
+ const WT_ROOT = path.join(store.DATA_DIR, 'worktrees');
15
+ const BRANCH_PREFIX = 'ac/';
16
+ const DIFF_MAX_FILES = 50; // diff 视图最多展示文件数
17
+ const DIFF_MAX_PATCH = 20 * 1024; // 单文件 patch 截断
18
+ const DIFF_MAX_TOTAL = 200 * 1024; // 总 patch 截断
19
+
20
+ // ---------- 任务级 cwd 注入(AsyncLocalStorage) ----------
21
+ const taskCwdStorage = new AsyncLocalStorage();
22
+ function runWithTaskCwd(cwd, fn) {
23
+ return cwd ? taskCwdStorage.run({ cwd }, fn) : fn();
24
+ }
25
+ function currentTaskCwd() {
26
+ const s = taskCwdStorage.getStore();
27
+ if (!s || !s.cwd) return '';
28
+ try { if (fs.existsSync(s.cwd) && fs.statSync(s.cwd).isDirectory()) return s.cwd; } catch { /* ignore */ }
29
+ return '';
30
+ }
31
+
32
+ // ---------- git 基础 ----------
33
+ function git(args, opts = {}) {
34
+ return new Promise((resolve) => {
35
+ execFile('git', args, {
36
+ cwd: opts.cwd,
37
+ timeout: opts.timeout || 30000,
38
+ maxBuffer: 8 * 1024 * 1024,
39
+ windowsHide: true
40
+ }, (err, so, se) => {
41
+ resolve({ code: err ? (err.code === undefined ? 1 : err.code) : 0, so: String(so || ''), se: String(se || '') });
42
+ });
43
+ });
44
+ }
45
+
46
+ // 工作目录是否为可用 git 仓库(有至少一个提交)
47
+ async function isGitRepo(dir) {
48
+ if (!dir) return false;
49
+ const r = await git(['rev-parse', '--is-inside-work-tree'], { cwd: dir });
50
+ if (String(r.so).trim() !== 'true') return false;
51
+ const h = await git(['rev-parse', 'HEAD'], { cwd: dir });
52
+ return h.code === 0 && h.so.trim() !== '';
53
+ }
54
+
55
+ function wtDirOf(taskId) { return path.join(WT_ROOT, String(taskId).replace(/[^\w-]/g, '')); }
56
+ function branchOf(taskId) { return BRANCH_PREFIX + String(taskId).replace(/[^\w-]/g, ''); }
57
+
58
+ // ---------- 创建 / 清理 ----------
59
+ // 为任务创建隔离 worktree;返回 { dir, branch, base } 或 null(非 git 仓库 / git 不可用 / 目录残留等)
60
+ async function createForTask(taskId) {
61
+ try {
62
+ const { resolveCwd } = require('./agent');
63
+ const baseCwd = resolveCwd();
64
+ if (!(await isGitRepo(baseCwd))) return null;
65
+ const head = await git(['rev-parse', 'HEAD'], { cwd: baseCwd });
66
+ const base = head.so.trim();
67
+ if (!base) return null;
68
+ const dir = wtDirOf(taskId);
69
+ const branch = branchOf(taskId);
70
+ // 残留清理(上次异常退出可能留下同名 worktree/分支)
71
+ await git(['worktree', 'remove', '--force', dir], { cwd: baseCwd });
72
+ await git(['branch', '-D', branch], { cwd: baseCwd });
73
+ const r = await git(['worktree', 'add', '-b', branch, dir, 'HEAD'], { cwd: baseCwd });
74
+ if (r.code !== 0) return null;
75
+ return { dir, branch, base, createdAt: Date.now() };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // 从 worktree 目录反查主仓库根目录
82
+ async function findMainRepo(dir) {
83
+ const g = await git(['rev-parse', '--git-common-dir'], { cwd: dir });
84
+ if (g.code !== 0) return '';
85
+ const common = g.so.trim();
86
+ if (!common) return '';
87
+ let main = path.resolve(dir, common.replace(/[/\\]\.git$/, ''));
88
+ try { if (!fs.statSync(main).isDirectory()) return ''; } catch { return ''; }
89
+ return main;
90
+ }
91
+
92
+ // 删除 worktree 与对应分支;返回 { ok, error }
93
+ async function removeForTask(worktree, { keepBranch = false } = {}) {
94
+ if (!worktree || !worktree.dir) return { ok: false, error: '无隔离区信息' };
95
+ let mainRepo = worktree.mainRepo || '';
96
+ if (!mainRepo && fs.existsSync(worktree.dir)) mainRepo = await findMainRepo(worktree.dir);
97
+ const r1 = await git(['worktree', 'remove', '--force', worktree.dir], { cwd: mainRepo || undefined });
98
+ let r2 = { code: 0 };
99
+ if (!keepBranch && worktree.branch) r2 = await git(['branch', '-D', worktree.branch], { cwd: mainRepo || undefined });
100
+ const ok = r1.code === 0; // 分支删除失败不阻塞(可能已被合并删除)
101
+ return { ok, error: ok ? '' : String((r1.se || r1.so || '').split('\n')[0] || '清理失败').slice(0, 200) };
102
+ }
103
+
104
+ // ---------- 改动查看 ----------
105
+ // 统计与明细:base(创建时 HEAD)→ 当前工作区(含未提交与未跟踪)
106
+ async function diff(worktree, { withPatch = true } = {}) {
107
+ if (!worktree || !worktree.dir) throw new Error('无隔离区信息');
108
+ const dir = worktree.dir;
109
+ const base = worktree.base || 'HEAD';
110
+ // add -A 仅为了让未跟踪文件进入 diff(不动分支指针)
111
+ await git(['add', '-A'], { cwd: dir });
112
+ const st = await git(['diff', '--numstat', base, '--'], { cwd: dir });
113
+ const stat = [];
114
+ for (const line of st.so.split('\n')) {
115
+ if (!line.trim()) continue;
116
+ const parts = line.split('\t');
117
+ if (parts.length < 3) continue;
118
+ stat.push({ file: parts[2], add: parts[0] === '-' ? null : Number(parts[0]), del: parts[1] === '-' ? null : Number(parts[1]) });
119
+ if (stat.length >= DIFF_MAX_FILES) break;
120
+ }
121
+ const out = { base, branch: worktree.branch, dir, stat, files: [] };
122
+ if (!withPatch || !stat.length) return out;
123
+ let total = 0;
124
+ for (const s of stat) {
125
+ if (total >= DIFF_MAX_TOTAL) { out.truncated = true; break; }
126
+ const p = await git(['diff', base, '--', s.file], { cwd: dir });
127
+ let patch = p.so;
128
+ if (patch.length > DIFF_MAX_PATCH) { patch = patch.slice(0, DIFF_MAX_PATCH) + '\n…(已截断)'; out.truncated = true; }
129
+ total += patch.length;
130
+ out.files.push({ path: s.file, patch });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // ---------- 合并回主工作目录 ----------
136
+ // worktree 内未提交改动先提交,再在主仓库 merge 该分支
137
+ async function mergeToMain(worktree, title) {
138
+ if (!worktree || !worktree.dir) throw new Error('无隔离区信息');
139
+ const dir = worktree.dir;
140
+ // 找主仓库:worktree 里 git rev-parse --git-common-dir 指向主 .git
141
+ const mainRepo = await findMainRepo(dir);
142
+ // 1. worktree 内提交全部改动
143
+ const hasChange = await git(['status', '--porcelain'], { cwd: dir });
144
+ if (hasChange.code === 0 && hasChange.so.trim()) {
145
+ await git(['add', '-A'], { cwd: dir });
146
+ const cm = await git(['commit', '-m', `agents-chat: ${String(title || '任务').slice(0, 60)}`], { cwd: dir });
147
+ if (cm.code !== 0) return { ok: false, error: `隔离区提交失败:${String((cm.se || cm.so).split('\n')[0]).slice(0, 200)}` };
148
+ }
149
+ // 2. 主仓库合并(--no-edit 保留默认合并信息;无改动则 nothing to commit)
150
+ const mr = await git(['merge', '--no-edit', worktree.branch], { cwd: mainRepo || undefined });
151
+ const mOut = String((mr.so || '') + (mr.se || '')).trim();
152
+ if (/Already up to date|已是最新|Nothing to merge/i.test(mOut)) return { ok: true, already: true };
153
+ if (mr.code !== 0) {
154
+ // 合并冲突:回滚合并状态,让用户在隔离区自行处理后再试
155
+ await git(['merge', '--abort'], { cwd: mainRepo || undefined });
156
+ return { ok: false, error: `合并冲突(已还原主目录):${mOut.split('\n')[0].slice(0, 200)}` };
157
+ }
158
+ return { ok: true };
159
+ }
160
+
161
+ // ---------- 数据治理 ----------
162
+ // 清理无主(任务已删)或完结超期的 worktree;liveTasks: 当前任务数组
163
+ async function pruneStale(days, liveTasks) {
164
+ const stat = { worktrees: 0 };
165
+ const cutoff = Date.now() - Math.max(1, Number(days) || 15) * 24 * 3600 * 1000;
166
+ const live = new Map((liveTasks || []).filter(t => t.worktree).map(t => [wtDirOf(t.id), t]));
167
+ let names = [];
168
+ try { names = fs.readdirSync(WT_ROOT); } catch { return stat; }
169
+ for (const name of names) {
170
+ const dir = path.join(WT_ROOT, name);
171
+ try {
172
+ if (!fs.statSync(dir).isDirectory()) continue;
173
+ const task = live.get(dir);
174
+ const w = task ? task.worktree : { dir, branch: branchOf(name), mainRepo: '' };
175
+ let stale = !task;
176
+ if (task) {
177
+ const end = Number(task.updatedAt || task.createdAt) || 0;
178
+ stale = (task.status === 'done' || task.status === 'failed') && end && end < cutoff;
179
+ }
180
+ if (!stale) continue;
181
+ const r = await removeForTask(w);
182
+ if (r.ok) stat.worktrees++;
183
+ } catch { /* 单项失败跳过 */ }
184
+ }
185
+ return stat;
186
+ }
187
+
188
+ module.exports = {
189
+ WT_ROOT, DIFF_MAX_FILES,
190
+ runWithTaskCwd, currentTaskCwd,
191
+ isGitRepo, createForTask, removeForTask, diff, mergeToMain, pruneStale
192
+ };
@@ -33,10 +33,15 @@ if (behavior === 'solo-chat') {
33
33
  `已处理完成 ${DEMO_TAG}`
34
34
  ]);
35
35
  } else if (behavior === 'solo-task') {
36
- // 单聊任务执行演示:输出任务结果(作为 result 持久化)
36
+ // 单聊任务执行演示:在工作目录真实写入一个成果文件(供 Git 隔离 diff 演示),再流式输出任务结果
37
+ try {
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+ fs.writeFileSync(path.join(process.cwd(), 'demo-solo-output.md'), '# 单聊任务演示产出\n\n演示模式下写入工作目录的成果文件。\n');
41
+ } catch { /* 只读目录时跳过 */ }
37
42
  streamOutput([
38
43
  `[单聊任务] 开始处理:${prompt.slice(0, 120)}`,
39
- `任务完成:产出模拟结果 ${DEMO_TAG}`
44
+ `任务完成:产出模拟结果(已写入 demo-solo-output.md) ${DEMO_TAG}`
40
45
  ]);
41
46
  } else if (behavior === 'echo') {
42
47
  if (prompt.includes('【委派背景】')) {
@@ -2,7 +2,10 @@
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#07c160">
7
+ <link rel="manifest" href="/manifest.json">
8
+ <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
6
9
  <title>多任务编排</title>
7
10
  <style>
8
11
  :root{
@@ -202,6 +205,15 @@
202
205
  .update-bar{position:fixed;top:0;left:0;right:0;z-index:60;background:#fffbe6;border-bottom:1px solid #ffe58f;color:#614700;font-size:12.5px;padding:7px 14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
203
206
  .update-bar button{border:1px solid #d8b94a;background:#fff;border-radius:5px;padding:2px 10px;font-size:12px;cursor:pointer;color:#614700}
204
207
  .update-bar button:hover{background:#fff7d6}
208
+ /* 小屏增强:顶栏收纳 + 看板横向滚动 + 安全区 */
209
+ @media (max-width:760px){
210
+ header{flex-wrap:wrap;gap:6px;padding:8px 10px}
211
+ header .btn{padding:5px 8px;font-size:12px}
212
+ .filter-box input{width:90px}
213
+ .board{grid-template-columns:repeat(4,minmax(220px,1fr));overflow-x:auto;padding-bottom:calc(6px + env(safe-area-inset-bottom))}
214
+ .modal{width:96vw;max-height:92vh}
215
+ #maskDetail .detail-log{max-height:36vh}
216
+ }
205
217
  </style>
206
218
  </head>
207
219
  <body>
@@ -1108,6 +1120,13 @@ es.onmessage = (e)=>{
1108
1120
  refreshProcs();
1109
1121
  // 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
1110
1122
  if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
1123
+ // 系统级通知:开关开启且页面后台时,卡牌完成/失败弹系统通知(与群聊页共用开关)
1124
+ try{
1125
+ if(ev.type==='task_done' && localStorage.getItem('sysNotify')==='1' && document.hidden && typeof Notification!=='undefined' && Notification.permission==='granted'){
1126
+ const n=new Notification((ev.status==='failed'?'❌ 卡牌失败:':'✅ 卡牌完成:')+(ev.title||''),{tag:'agents-chat-card'});
1127
+ n.onclick=()=>{ window.focus(); n.close(); };
1128
+ }
1129
+ }catch(e){}
1111
1130
  if(OPEN_ID && ev.cardId===OPEN_ID){
1112
1131
  const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
1113
1132
  if(ev.type==='task_start') TEXT_PARTS.clear();
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" rx="24" fill="#07c160"/>
3
+ <circle cx="40" cy="52" r="12" fill="#fff"/>
4
+ <circle cx="88" cy="52" r="12" fill="#fff"/>
5
+ <path d="M34 86c8 10 22 14 30 14s22-4 30-14" stroke="#fff" stroke-width="9" fill="none" stroke-linecap="round"/>
6
+ </svg>