metame-cli 1.4.15 → 1.4.18

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/scripts/daemon.js CHANGED
@@ -49,14 +49,38 @@ try { skillEvolution = require('./skill-evolution'); } catch { /* graceful fallb
49
49
  // ---------------------------------------------------------
50
50
  // SKILL ROUTING (keyword → /skillname prefix, like metame-desktop)
51
51
  // ---------------------------------------------------------
52
+ function isMacLocalOrchestratorIntent(prompt) {
53
+ const text = String(prompt || '').trim();
54
+ if (!text) return false;
55
+
56
+ // Explicit macOS automation keywords.
57
+ if (/\b(?:mac|macos|applescript|osascript|jxa|hammerspoon|aerospace|yabai|skhd|raycast|launchctl|keyboard maestro)\b/i.test(text)) {
58
+ return true;
59
+ }
60
+ if (/(自动化|辅助功能|系统设置|隐私|权限|锁屏|锁定屏幕|睡眠|休眠|静音|取消静音|音量)/.test(text)) {
61
+ return true;
62
+ }
63
+
64
+ // General verbs must be paired with explicit macOS targets to avoid over-routing.
65
+ const hasAction = /(?:打开|关闭|启动|退出|切到|唤起|锁屏|锁定屏幕|睡眠|休眠|静音|取消静音|调(?:高|低|整)?音量|open|launch|quit|activate|lock\s*screen|sleep|mute|unmute)/i.test(text);
66
+ const hasTarget = /(?:微信|WeChat|飞书|Feishu|Finder|Safari|Terminal|iTerm|系统设置|System Settings|电脑|System Events|mac)/i.test(text);
67
+ return hasAction && hasTarget;
68
+ }
69
+
52
70
  const SKILL_ROUTES = [
53
71
  { name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
72
+ { name: 'macos-local-orchestrator', match: isMacLocalOrchestratorIntent },
54
73
  { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
74
+ { name: 'skill-manager', pattern: /找技能|管理技能|更新技能|安装技能|skill manager|skill scout|(?:find|look for)\s+skills?/i },
75
+ { name: 'skill-evolution-manager', pattern: /\/evolve\b|复盘一下|记录一下(这个)?经验|保存到\s*skill|skill evolution/i },
55
76
  ];
56
77
 
57
78
  function routeSkill(prompt) {
58
79
  for (const r of SKILL_ROUTES) {
59
- if (r.pattern.test(prompt)) return r.name;
80
+ const matched = typeof r.match === 'function'
81
+ ? r.match(prompt)
82
+ : (r.pattern ? r.pattern.test(prompt) : false);
83
+ if (matched) return r.name;
60
84
  }
61
85
  return null;
62
86
  }
@@ -79,6 +103,12 @@ function routeAgent(prompt, config) {
79
103
 
80
104
  const yaml = require('./resolve-yaml');
81
105
  const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
106
+ const {
107
+ USAGE_RETENTION_DAYS_DEFAULT,
108
+ normalizeUsageCategory,
109
+ } = require('./usage-classifier');
110
+ const { createTaskBoard } = require('./task-board');
111
+ const taskEnvelope = require('./daemon-task-envelope');
82
112
  const { createAdminCommandHandler } = require('./daemon-admin-commands');
83
113
  const { createExecCommandHandler } = require('./daemon-exec-commands');
84
114
  const { createOpsCommandHandler } = require('./daemon-ops-commands');
@@ -229,19 +259,56 @@ function restoreConfig() {
229
259
 
230
260
  let _cachedState = null;
231
261
 
262
+ function ensureUsageShape(state) {
263
+ if (!state.usage || typeof state.usage !== 'object') state.usage = {};
264
+ if (!state.usage.categories || typeof state.usage.categories !== 'object') state.usage.categories = {};
265
+ if (!state.usage.daily || typeof state.usage.daily !== 'object') state.usage.daily = {};
266
+ const keepDays = Number(state.usage.retention_days);
267
+ state.usage.retention_days = Number.isFinite(keepDays) && keepDays >= 7
268
+ ? Math.floor(keepDays)
269
+ : USAGE_RETENTION_DAYS_DEFAULT;
270
+ }
271
+
272
+ function ensureStateShape(state) {
273
+ if (!state || typeof state !== 'object') return {
274
+ pid: null,
275
+ budget: { date: null, tokens_used: 0 },
276
+ tasks: {},
277
+ sessions: {},
278
+ started_at: null,
279
+ usage: { retention_days: USAGE_RETENTION_DAYS_DEFAULT, categories: {}, daily: {} },
280
+ };
281
+ if (!state.budget || typeof state.budget !== 'object') state.budget = { date: null, tokens_used: 0 };
282
+ if (typeof state.budget.tokens_used !== 'number') state.budget.tokens_used = Number(state.budget.tokens_used) || 0;
283
+ if (!Object.prototype.hasOwnProperty.call(state.budget, 'date')) state.budget.date = null;
284
+ if (!state.tasks || typeof state.tasks !== 'object') state.tasks = {};
285
+ if (!state.sessions || typeof state.sessions !== 'object') state.sessions = {};
286
+ ensureUsageShape(state);
287
+ return state;
288
+ }
289
+
290
+ function pruneDailyUsage(usage, todayIso) {
291
+ const keepDays = usage.retention_days || USAGE_RETENTION_DAYS_DEFAULT;
292
+ const cutoff = new Date(`${todayIso}T00:00:00.000Z`);
293
+ cutoff.setUTCDate(cutoff.getUTCDate() - (keepDays - 1));
294
+ const cutoffIso = cutoff.toISOString().slice(0, 10);
295
+ for (const day of Object.keys(usage.daily || {})) {
296
+ if (day < cutoffIso) delete usage.daily[day];
297
+ }
298
+ }
299
+
232
300
  function _readStateFromDisk() {
233
301
  try {
234
302
  const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
235
- if (!s.sessions) s.sessions = {};
236
- return s;
303
+ return ensureStateShape(s);
237
304
  } catch {
238
- return {
305
+ return ensureStateShape({
239
306
  pid: null,
240
307
  budget: { date: null, tokens_used: 0 },
241
308
  tasks: {},
242
309
  sessions: {},
243
310
  started_at: null,
244
- };
311
+ });
245
312
  }
246
313
  }
247
314
 
@@ -251,9 +318,65 @@ function loadState() {
251
318
  }
252
319
 
253
320
  function saveState(state) {
254
- _cachedState = state;
321
+ const next = ensureStateShape(state);
322
+ if (_cachedState && _cachedState !== next) {
323
+ const current = ensureStateShape(_cachedState);
324
+
325
+ const currentBudgetDate = String(current.budget.date || '');
326
+ const nextBudgetDate = String(next.budget.date || '');
327
+ const currentBudgetTokens = Math.max(0, Math.floor(Number(current.budget.tokens_used) || 0));
328
+ const nextBudgetTokens = Math.max(0, Math.floor(Number(next.budget.tokens_used) || 0));
329
+ if (currentBudgetDate && (!nextBudgetDate || currentBudgetDate > nextBudgetDate)) {
330
+ next.budget.date = currentBudgetDate;
331
+ next.budget.tokens_used = currentBudgetTokens;
332
+ } else if (currentBudgetDate && currentBudgetDate === nextBudgetDate) {
333
+ next.budget.tokens_used = Math.max(currentBudgetTokens, nextBudgetTokens);
334
+ }
335
+
336
+ const currentKeepDays = Number(current.usage.retention_days) || USAGE_RETENTION_DAYS_DEFAULT;
337
+ const nextKeepDays = Number(next.usage.retention_days) || USAGE_RETENTION_DAYS_DEFAULT;
338
+ next.usage.retention_days = Math.max(currentKeepDays, nextKeepDays);
339
+
340
+ for (const [category, curMeta] of Object.entries(current.usage.categories || {})) {
341
+ if (!next.usage.categories[category] || typeof next.usage.categories[category] !== 'object') {
342
+ next.usage.categories[category] = {};
343
+ }
344
+ const curTotal = Math.max(0, Math.floor(Number(curMeta && curMeta.total) || 0));
345
+ const nextTotal = Math.max(0, Math.floor(Number(next.usage.categories[category].total) || 0));
346
+ if (curTotal > nextTotal) next.usage.categories[category].total = curTotal;
347
+
348
+ const curUpdated = String(curMeta && curMeta.updated_at || '');
349
+ const nextUpdated = String(next.usage.categories[category].updated_at || '');
350
+ if (curUpdated && curUpdated > nextUpdated) next.usage.categories[category].updated_at = curUpdated;
351
+ }
352
+
353
+ for (const [day, curDayUsageRaw] of Object.entries(current.usage.daily || {})) {
354
+ const curDayUsage = (curDayUsageRaw && typeof curDayUsageRaw === 'object') ? curDayUsageRaw : {};
355
+ if (!next.usage.daily[day] || typeof next.usage.daily[day] !== 'object') {
356
+ next.usage.daily[day] = {};
357
+ }
358
+ const nextDayUsage = next.usage.daily[day];
359
+ for (const [key, curValue] of Object.entries(curDayUsage)) {
360
+ const curNum = Math.max(0, Math.floor(Number(curValue) || 0));
361
+ const nextNum = Math.max(0, Math.floor(Number(nextDayUsage[key]) || 0));
362
+ if (curNum > nextNum) nextDayUsage[key] = curNum;
363
+ }
364
+ const categorySum = Object.entries(nextDayUsage)
365
+ .filter(([key]) => key !== 'total')
366
+ .reduce((sum, [, value]) => sum + Math.max(0, Math.floor(Number(value) || 0)), 0);
367
+ nextDayUsage.total = Math.max(Math.max(0, Math.floor(Number(nextDayUsage.total) || 0)), categorySum);
368
+ }
369
+
370
+ const currentUsageUpdated = String(current.usage.updated_at || '');
371
+ const nextUsageUpdated = String(next.usage.updated_at || '');
372
+ if (currentUsageUpdated && currentUsageUpdated > nextUsageUpdated) {
373
+ next.usage.updated_at = currentUsageUpdated;
374
+ }
375
+ }
376
+
377
+ _cachedState = next;
255
378
  try {
256
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
379
+ fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2), 'utf8');
257
380
  } catch (e) {
258
381
  log('ERROR', `Failed to save state: ${e.message}`);
259
382
  }
@@ -287,6 +410,7 @@ function buildProfilePreamble() {
287
410
  // BUDGET TRACKING
288
411
  // ---------------------------------------------------------
289
412
  function checkBudget(config, state) {
413
+ state = ensureStateShape(state);
290
414
  const today = new Date().toISOString().slice(0, 10);
291
415
  if (state.budget.date !== today) {
292
416
  state.budget.date = today;
@@ -297,14 +421,44 @@ function checkBudget(config, state) {
297
421
  return state.budget.tokens_used < limit;
298
422
  }
299
423
 
300
- function recordTokens(state, tokens) {
424
+ function recordTokens(state, tokens, meta = null) {
425
+ const amount = Math.max(0, Math.floor(Number(tokens) || 0));
426
+ if (!amount) return;
427
+
428
+ const liveState = ensureStateShape(loadState());
301
429
  const today = new Date().toISOString().slice(0, 10);
302
- if (state.budget.date !== today) {
303
- state.budget.date = today;
304
- state.budget.tokens_used = 0;
430
+ if (liveState.budget.date !== today) {
431
+ liveState.budget.date = today;
432
+ liveState.budget.tokens_used = 0;
305
433
  }
306
- state.budget.tokens_used += tokens;
307
- saveState(state);
434
+ liveState.budget.tokens_used += amount;
435
+
436
+ const category = normalizeUsageCategory(meta && meta.category, {
437
+ logger: (msg) => log('WARN', `[USAGE] ${msg}`),
438
+ });
439
+ ensureUsageShape(liveState);
440
+
441
+ if (!liveState.usage.categories[category] || typeof liveState.usage.categories[category] !== 'object') {
442
+ liveState.usage.categories[category] = { total: 0 };
443
+ }
444
+ liveState.usage.categories[category].total = (Number(liveState.usage.categories[category].total) || 0) + amount;
445
+ liveState.usage.categories[category].updated_at = new Date().toISOString();
446
+
447
+ if (!liveState.usage.daily[today] || typeof liveState.usage.daily[today] !== 'object') {
448
+ liveState.usage.daily[today] = { total: 0 };
449
+ }
450
+ const dayUsage = liveState.usage.daily[today];
451
+ dayUsage.total = (Number(dayUsage.total) || 0) + amount;
452
+ dayUsage[category] = (Number(dayUsage[category]) || 0) + amount;
453
+ liveState.usage.updated_at = new Date().toISOString();
454
+ pruneDailyUsage(liveState.usage, today);
455
+
456
+ if (state && typeof state === 'object' && state !== liveState) {
457
+ state.budget = liveState.budget;
458
+ state.usage = liveState.usage;
459
+ }
460
+
461
+ saveState(liveState);
308
462
  }
309
463
 
310
464
 
@@ -317,6 +471,10 @@ function getBudgetWarning(config, state) {
317
471
  return 'ok';
318
472
  }
319
473
 
474
+ const taskBoard = createTaskBoard({
475
+ logger: (msg) => log('WARN', msg),
476
+ });
477
+
320
478
  // ---------------------------------------------------------
321
479
  // AGENT DISPATCH — virtual chatId inter-agent communication
322
480
  // ---------------------------------------------------------
@@ -349,25 +507,29 @@ function createNullBot(onOutput) {
349
507
  * Forward bot: routes all calls to a real bot with a fixed chatId.
350
508
  * Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
351
509
  */
352
- function createStreamForwardBot(realBot, chatId) {
510
+ function createStreamForwardBot(realBot, chatId, onOutput = null) {
353
511
  // Track edit-broken state independently so dispatch failures don't poison realBot's flag
354
512
  let _editBroken = false;
355
513
  return {
356
514
  sendMessage: async (_, text) => {
357
515
  log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
516
+ if (onOutput) onOutput(text);
358
517
  return realBot.sendMessage(chatId, text);
359
518
  },
360
519
  sendMarkdown: async (_, text) => {
361
520
  log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
521
+ if (onOutput) onOutput(text);
362
522
  return realBot.sendMarkdown(chatId, text);
363
523
  },
364
524
  sendCard: async (_, card) => {
365
525
  const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
366
526
  log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
527
+ if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card);
367
528
  return realBot.sendCard(chatId, card);
368
529
  },
369
530
  sendRawCard: async (_, header, elements) => {
370
531
  log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
532
+ if (onOutput) onOutput(header);
371
533
  return realBot.sendRawCard(chatId, header, elements);
372
534
  },
373
535
  sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
@@ -391,6 +553,76 @@ function createStreamForwardBot(realBot, chatId) {
391
553
  };
392
554
  }
393
555
 
556
+ function extractArtifactPaths(text) {
557
+ const out = new Set();
558
+ const src = String(text || '');
559
+ const re = /\[\[FILE:([^\]]+)\]\]/g;
560
+ let m;
561
+ while ((m = re.exec(src)) !== null) {
562
+ const v = String(m[1] || '').trim();
563
+ if (v) out.add(v.slice(0, 500));
564
+ }
565
+ return [...out];
566
+ }
567
+
568
+ function inferTaskStatusFromOutput(text) {
569
+ const s = String(text || '');
570
+ if (/(^|\b)(blocked|卡住|阻塞|waiting for|等待)(\b|$)/i.test(s)) return 'blocked';
571
+ if (/(^|\b)(failed|失败|error|异常|报错)(\b|$)/i.test(s)) return 'failed';
572
+ return 'done';
573
+ }
574
+
575
+ function summarizeTaskInputs(inputs) {
576
+ if (!inputs || typeof inputs !== 'object') return '(none)';
577
+ const lines = [];
578
+ for (const [k, v] of Object.entries(inputs)) {
579
+ if (typeof v === 'string') lines.push(`- ${k}: ${v}`);
580
+ else lines.push(`- ${k}: ${JSON.stringify(v)}`);
581
+ if (lines.length >= 8) break;
582
+ }
583
+ return lines.length > 0 ? lines.join('\n') : '(none)';
584
+ }
585
+
586
+ function buildDispatchChatId(targetProject, scopeId) {
587
+ const target = String(targetProject || '').trim();
588
+ if (!target) return '_agent_unknown';
589
+ const safeScope = taskEnvelope && taskEnvelope.normalizeScopeId
590
+ ? taskEnvelope.normalizeScopeId(scopeId, '')
591
+ : '';
592
+ if (safeScope) return `_scope_${safeScope}__${target}`;
593
+ return `_agent_${target}`;
594
+ }
595
+
596
+ function buildPromptFromTaskEnvelope(envelope, fallbackPrompt) {
597
+ const goal = envelope.goal || fallbackPrompt || 'No goal provided';
598
+ const dod = Array.isArray(envelope.definition_of_done) && envelope.definition_of_done.length > 0
599
+ ? envelope.definition_of_done.map((x, i) => `${i + 1}. ${x}`).join('\n')
600
+ : '1. 给出可执行的结果与关键结论\n2. 给出相关产物路径(如有)';
601
+ const inputs = summarizeTaskInputs(envelope.inputs || {});
602
+ const taskId = envelope.task_id || 'unknown';
603
+ const scopeId = envelope.scope_id || taskId;
604
+ const participants = Array.isArray(envelope.participants) && envelope.participants.length > 0
605
+ ? envelope.participants.join(', ')
606
+ : `${envelope.from_agent || 'unknown'}, ${envelope.to_agent || 'unknown'}`;
607
+ return [
608
+ `任务ID: ${taskId}`,
609
+ `协作Scope: ${scopeId}`,
610
+ `参与Agent: ${participants}`,
611
+ `任务目标: ${goal}`,
612
+ '',
613
+ '完成标准 (DoD):',
614
+ dod,
615
+ '',
616
+ '输入上下文:',
617
+ inputs,
618
+ '',
619
+ '执行要求:',
620
+ '1. 先用1-2句“计划:...”说明方案',
621
+ '2. 再执行任务',
622
+ '3. 结尾给出“结果摘要:...”和“产物:...”',
623
+ ].join('\n');
624
+ }
625
+
394
626
  /**
395
627
  * Dispatch a task/message to another agent via virtual chatId.
396
628
  * @param {string} targetProject - project key (e.g. 'digital_me', 'desktop')
@@ -400,17 +632,56 @@ function createStreamForwardBot(realBot, chatId) {
400
632
  */
401
633
  function dispatchTask(targetProject, message, config, replyFn, streamOptions = null) {
402
634
  const LIMITS = { max_per_hour_per_target: 20, max_total_per_hour: 60, max_depth: 2 };
635
+ const payload = (message && message.payload && typeof message.payload === 'object')
636
+ ? message.payload
637
+ : {};
638
+
639
+ let envelope = null;
640
+ if (payload.task_envelope) {
641
+ try {
642
+ envelope = taskEnvelope.normalizeTaskEnvelope(payload.task_envelope, {
643
+ from_agent: message.from || 'unknown',
644
+ to_agent: targetProject,
645
+ task_kind: payload.task_envelope && payload.task_envelope.task_kind ? payload.task_envelope.task_kind : 'team',
646
+ });
647
+ const checked = taskEnvelope.validateTaskEnvelope(envelope);
648
+ if (!checked.ok) {
649
+ log('WARN', `Dispatch blocked: invalid task_envelope (${checked.error})`);
650
+ return { success: false, error: `invalid_task_envelope:${checked.error}` };
651
+ }
652
+ } catch (e) {
653
+ log('WARN', `Dispatch blocked: task_envelope parse failed (${e.message})`);
654
+ return { success: false, error: 'invalid_task_envelope' };
655
+ }
656
+ }
657
+
658
+ const markTaskBlocked = (reason) => {
659
+ if (!envelope || !taskBoard) return;
660
+ const nowIso = new Date().toISOString();
661
+ taskBoard.upsertTask({
662
+ ...envelope,
663
+ status: 'blocked',
664
+ last_error: reason,
665
+ updated_at: nowIso,
666
+ });
667
+ taskBoard.appendTaskEvent(envelope.task_id, 'dispatch_blocked', message.from || 'system', {
668
+ reason,
669
+ target: targetProject,
670
+ });
671
+ };
403
672
 
404
673
  // Anti-storm: check chain depth
405
674
  const chain = message.chain || [];
406
675
  if (chain.length >= LIMITS.max_depth) {
407
676
  log('WARN', `Dispatch blocked: max depth ${LIMITS.max_depth} reached (chain: ${chain.join('→')})`);
677
+ markTaskBlocked('max_depth_exceeded');
408
678
  return { success: false, error: 'max_depth_exceeded' };
409
679
  }
410
680
 
411
681
  // Anti-storm: check for cycles
412
682
  if (chain.includes(targetProject)) {
413
683
  log('WARN', `Dispatch blocked: cycle detected (${chain.join('→')}→${targetProject})`);
684
+ markTaskBlocked('cycle_detected');
414
685
  return { success: false, error: 'cycle_detected' };
415
686
  }
416
687
 
@@ -425,10 +696,12 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
425
696
  const toTarget = recent.filter(e => e.to === targetProject).length;
426
697
  if (toTarget >= LIMITS.max_per_hour_per_target) {
427
698
  log('WARN', `Dispatch blocked: rate limit to ${targetProject} (${toTarget}/${LIMITS.max_per_hour_per_target} per hour)`);
699
+ markTaskBlocked('rate_limit_target');
428
700
  return { success: false, error: 'rate_limit_target' };
429
701
  }
430
702
  if (recent.length >= LIMITS.max_total_per_hour) {
431
703
  log('WARN', `Dispatch blocked: total rate limit (${recent.length}/${LIMITS.max_total_per_hour} per hour)`);
704
+ markTaskBlocked('rate_limit_total');
432
705
  return { success: false, error: 'rate_limit_total' };
433
706
  }
434
707
  }
@@ -438,6 +711,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
438
711
 
439
712
  if (!_handleCommand) {
440
713
  log('WARN', 'Dispatch: handleCommand not yet bound, dropping task');
714
+ markTaskBlocked('handler_not_ready');
441
715
  return { success: false, error: 'handler_not_ready' };
442
716
  }
443
717
 
@@ -447,18 +721,52 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
447
721
  to: targetProject,
448
722
  type: message.type || 'task',
449
723
  priority: message.priority || 'normal',
450
- payload: message.payload || {},
724
+ payload,
451
725
  callback: message.callback || false,
452
726
  new_session: !!message.new_session,
453
727
  chain: [...chain, message.from || 'unknown'],
728
+ task_id: envelope ? envelope.task_id : null,
729
+ scope_id: envelope ? envelope.scope_id : null,
454
730
  created_at: new Date().toISOString(),
455
731
  };
456
732
 
733
+ if (envelope && taskBoard) {
734
+ const nowIso = new Date().toISOString();
735
+ taskBoard.upsertTask({
736
+ ...envelope,
737
+ status: 'queued',
738
+ updated_at: nowIso,
739
+ });
740
+ taskBoard.appendTaskEvent(envelope.task_id, 'dispatch_enqueued', fullMsg.from || 'system', {
741
+ dispatch_id: fullMsg.id,
742
+ target: targetProject,
743
+ priority: fullMsg.priority,
744
+ scope_id: envelope.scope_id,
745
+ });
746
+ taskBoard.recordHandoff({
747
+ handoff_id: taskEnvelope.newHandoffId(),
748
+ task_id: envelope.task_id,
749
+ from_agent: envelope.from_agent || fullMsg.from || 'unknown',
750
+ to_agent: targetProject,
751
+ payload: {
752
+ dispatch_id: fullMsg.id,
753
+ scope_id: envelope.scope_id,
754
+ title: payload.title || '',
755
+ prompt: payload.prompt || '',
756
+ },
757
+ status: 'sent',
758
+ created_at: nowIso,
759
+ updated_at: nowIso,
760
+ });
761
+ }
762
+
457
763
  // Write to dispatch log for audit / rate-limiting
458
764
  if (!fs.existsSync(DISPATCH_DIR)) fs.mkdirSync(DISPATCH_DIR, { recursive: true });
459
765
  fs.appendFileSync(DISPATCH_LOG, JSON.stringify({ ...fullMsg, dispatched_at: new Date().toISOString() }) + '\n', 'utf8');
460
766
 
461
- const rawPrompt = fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided';
767
+ const rawPrompt = envelope
768
+ ? buildPromptFromTaskEnvelope(envelope, fullMsg.payload.prompt || fullMsg.payload.title || '')
769
+ : (fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided');
462
770
 
463
771
  // Inject sender identity when dispatched by another agent (not directly from user)
464
772
  const userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
@@ -476,18 +784,35 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
476
784
  // Daemon sends the ack autonomously; Claude should just state its plan in the reply text.
477
785
  prompt = `[行为要求:回复开头用1-2句「计划:xxx」说明执行方案,再开始执行。不要调用 dispatch_to,daemon 会自动转发你的回复。]\n\n${prompt}`;
478
786
 
479
- // Prefer target's real Feishu chatId so dispatch reuses the existing session
480
- // (--resume, no CLAUDE.md re-read, no token waste). Fall back to _agent_* virtual
481
- // All dispatches use _agent_* virtual chatId to ensure a clean session with
482
- // the correct project context. Real Feishu chatIds are only for direct user messages.
787
+ // team task with scope_id uses scoped virtual chatId:
788
+ // _scope_<scope_id>__<agent>
789
+ // which allows N-agent collaboration under the same task scope while
790
+ // keeping per-agent execution sessions isolated.
483
791
  const forceNew = !!fullMsg.new_session;
484
- const dispatchChatId = `_agent_${targetProject}`;
792
+ const dispatchChatId = buildDispatchChatId(targetProject, envelope && envelope.scope_id);
485
793
  const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
486
794
  log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
487
795
 
796
+ let _taskFinalized = false;
488
797
  const outputHandler = (output) => {
489
798
  const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
490
799
  log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
800
+ if (envelope && taskBoard && !_taskFinalized && outStr.trim().length > 2) {
801
+ const status = inferTaskStatusFromOutput(outStr);
802
+ const artifacts = extractArtifactPaths(outStr);
803
+ const update = {
804
+ summary: outStr.slice(0, 1200),
805
+ artifacts,
806
+ };
807
+ if (status === 'failed') update.last_error = outStr.slice(0, 400);
808
+ taskBoard.markTaskStatus(envelope.task_id, status, update);
809
+ taskBoard.appendTaskEvent(envelope.task_id, 'task_result', targetProject, {
810
+ status,
811
+ preview: outStr.slice(0, 240),
812
+ artifact_count: artifacts.length,
813
+ });
814
+ _taskFinalized = true;
815
+ }
491
816
  if (replyFn && outStr.trim().length > 2) {
492
817
  replyFn(outStr);
493
818
  } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
@@ -507,7 +832,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
507
832
  // If streamOptions provided, use real bot so output appears in target's Feishu channel.
508
833
  // Otherwise fall back to nullBot which captures output for replyFn.
509
834
  const nullBot = streamOptions?.bot && streamOptions?.chatId
510
- ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId)
835
+ ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler)
511
836
  : createNullBot(outputHandler);
512
837
  // Permission inheritance: if daemon runs with dangerously_skip_permissions, dispatched agents
513
838
  // inherit the same level — they need Write access for implementation tasks.
@@ -522,11 +847,19 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
522
847
  }
523
848
  }
524
849
  const dispatchReadOnly = !(config.daemon && config.daemon.dangerously_skip_permissions);
850
+ if (envelope && taskBoard) {
851
+ taskBoard.markTaskStatus(envelope.task_id, 'running', { summary: `dispatched via ${sessionMode}` });
852
+ taskBoard.appendTaskEvent(envelope.task_id, 'task_started', targetProject, { session_mode: sessionMode });
853
+ }
525
854
  _handleCommand(nullBot, dispatchChatId, prompt, config, null, null, dispatchReadOnly).catch(e => {
526
855
  log('ERROR', `Dispatch handleCommand failed for ${targetProject}: ${e.message}`);
856
+ if (envelope && taskBoard) {
857
+ taskBoard.markTaskStatus(envelope.task_id, 'failed', { last_error: e.message, summary: 'dispatch execution failed' });
858
+ taskBoard.appendTaskEvent(envelope.task_id, 'task_failed', targetProject, { error: e.message.slice(0, 200) });
859
+ }
527
860
  });
528
861
 
529
- return { success: true, id: fullMsg.id };
862
+ return { success: true, id: fullMsg.id, task_id: envelope ? envelope.task_id : null };
530
863
  }
531
864
 
532
865
  /**
@@ -755,8 +1088,8 @@ const {
755
1088
  */
756
1089
  function attachOrCreateSession(chatId, projCwd, name) {
757
1090
  const state = loadState();
758
- // Virtual agent chatIds (_agent_*) always get a fresh one-shot session.
759
- // They must not resume real sessions, to avoid concurrency conflicts.
1091
+ // Virtual chatIds (_agent_* / _scope_*) are isolated from real user chats.
1092
+ // This avoids cross-context session collisions between user chat and dispatch flows.
760
1093
  const newSess = createSession(chatId, projCwd, name || '');
761
1094
  state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
762
1095
  saveState(state);
@@ -1031,6 +1364,9 @@ const { handleAdminCommand } = createAdminCommandHandler({
1031
1364
  getAllTasks,
1032
1365
  dispatchTask,
1033
1366
  log,
1367
+ skillEvolution,
1368
+ taskBoard,
1369
+ taskEnvelope,
1034
1370
  });
1035
1371
 
1036
1372
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1286,7 +1622,19 @@ async function main() {
1286
1622
 
1287
1623
  // Config validation: warn on unknown/suspect fields
1288
1624
  const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
1289
- const KNOWN_DAEMON = ['model', 'log_max_size', 'heartbeat_check_interval', 'session_allowed_tools', 'dangerously_skip_permissions', 'cooldown_seconds', 'agent_flow_ttl_ms', 'agent_bind_ttl_ms'];
1625
+ const KNOWN_DAEMON = [
1626
+ 'model',
1627
+ 'log_max_size',
1628
+ 'heartbeat_check_interval',
1629
+ 'session_allowed_tools',
1630
+ 'dangerously_skip_permissions',
1631
+ 'cooldown_seconds',
1632
+ 'agent_flow_ttl_ms',
1633
+ 'agent_bind_ttl_ms',
1634
+ 'mac_control_mode',
1635
+ 'enable_nl_mac_control',
1636
+ 'enable_nl_mac_fallback',
1637
+ ];
1290
1638
  const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
1291
1639
  for (const key of Object.keys(config)) {
1292
1640
  if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);