metame-cli 1.4.17 → 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,8 +49,27 @@ 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 },
55
74
  { name: 'skill-manager', pattern: /找技能|管理技能|更新技能|安装技能|skill manager|skill scout|(?:find|look for)\s+skills?/i },
56
75
  { name: 'skill-evolution-manager', pattern: /\/evolve\b|复盘一下|记录一下(这个)?经验|保存到\s*skill|skill evolution/i },
@@ -58,7 +77,10 @@ const SKILL_ROUTES = [
58
77
 
59
78
  function routeSkill(prompt) {
60
79
  for (const r of SKILL_ROUTES) {
61
- 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;
62
84
  }
63
85
  return null;
64
86
  }
@@ -81,6 +103,12 @@ function routeAgent(prompt, config) {
81
103
 
82
104
  const yaml = require('./resolve-yaml');
83
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');
84
112
  const { createAdminCommandHandler } = require('./daemon-admin-commands');
85
113
  const { createExecCommandHandler } = require('./daemon-exec-commands');
86
114
  const { createOpsCommandHandler } = require('./daemon-ops-commands');
@@ -231,19 +259,56 @@ function restoreConfig() {
231
259
 
232
260
  let _cachedState = null;
233
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
+
234
300
  function _readStateFromDisk() {
235
301
  try {
236
302
  const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
237
- if (!s.sessions) s.sessions = {};
238
- return s;
303
+ return ensureStateShape(s);
239
304
  } catch {
240
- return {
305
+ return ensureStateShape({
241
306
  pid: null,
242
307
  budget: { date: null, tokens_used: 0 },
243
308
  tasks: {},
244
309
  sessions: {},
245
310
  started_at: null,
246
- };
311
+ });
247
312
  }
248
313
  }
249
314
 
@@ -253,9 +318,65 @@ function loadState() {
253
318
  }
254
319
 
255
320
  function saveState(state) {
256
- _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;
257
378
  try {
258
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
379
+ fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2), 'utf8');
259
380
  } catch (e) {
260
381
  log('ERROR', `Failed to save state: ${e.message}`);
261
382
  }
@@ -289,6 +410,7 @@ function buildProfilePreamble() {
289
410
  // BUDGET TRACKING
290
411
  // ---------------------------------------------------------
291
412
  function checkBudget(config, state) {
413
+ state = ensureStateShape(state);
292
414
  const today = new Date().toISOString().slice(0, 10);
293
415
  if (state.budget.date !== today) {
294
416
  state.budget.date = today;
@@ -299,14 +421,44 @@ function checkBudget(config, state) {
299
421
  return state.budget.tokens_used < limit;
300
422
  }
301
423
 
302
- 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());
303
429
  const today = new Date().toISOString().slice(0, 10);
304
- if (state.budget.date !== today) {
305
- state.budget.date = today;
306
- state.budget.tokens_used = 0;
430
+ if (liveState.budget.date !== today) {
431
+ liveState.budget.date = today;
432
+ liveState.budget.tokens_used = 0;
307
433
  }
308
- state.budget.tokens_used += tokens;
309
- 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);
310
462
  }
311
463
 
312
464
 
@@ -319,6 +471,10 @@ function getBudgetWarning(config, state) {
319
471
  return 'ok';
320
472
  }
321
473
 
474
+ const taskBoard = createTaskBoard({
475
+ logger: (msg) => log('WARN', msg),
476
+ });
477
+
322
478
  // ---------------------------------------------------------
323
479
  // AGENT DISPATCH — virtual chatId inter-agent communication
324
480
  // ---------------------------------------------------------
@@ -351,25 +507,29 @@ function createNullBot(onOutput) {
351
507
  * Forward bot: routes all calls to a real bot with a fixed chatId.
352
508
  * Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
353
509
  */
354
- function createStreamForwardBot(realBot, chatId) {
510
+ function createStreamForwardBot(realBot, chatId, onOutput = null) {
355
511
  // Track edit-broken state independently so dispatch failures don't poison realBot's flag
356
512
  let _editBroken = false;
357
513
  return {
358
514
  sendMessage: async (_, text) => {
359
515
  log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
516
+ if (onOutput) onOutput(text);
360
517
  return realBot.sendMessage(chatId, text);
361
518
  },
362
519
  sendMarkdown: async (_, text) => {
363
520
  log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
521
+ if (onOutput) onOutput(text);
364
522
  return realBot.sendMarkdown(chatId, text);
365
523
  },
366
524
  sendCard: async (_, card) => {
367
525
  const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
368
526
  log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
527
+ if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card);
369
528
  return realBot.sendCard(chatId, card);
370
529
  },
371
530
  sendRawCard: async (_, header, elements) => {
372
531
  log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
532
+ if (onOutput) onOutput(header);
373
533
  return realBot.sendRawCard(chatId, header, elements);
374
534
  },
375
535
  sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
@@ -393,6 +553,76 @@ function createStreamForwardBot(realBot, chatId) {
393
553
  };
394
554
  }
395
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
+
396
626
  /**
397
627
  * Dispatch a task/message to another agent via virtual chatId.
398
628
  * @param {string} targetProject - project key (e.g. 'digital_me', 'desktop')
@@ -402,17 +632,56 @@ function createStreamForwardBot(realBot, chatId) {
402
632
  */
403
633
  function dispatchTask(targetProject, message, config, replyFn, streamOptions = null) {
404
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
+ };
405
672
 
406
673
  // Anti-storm: check chain depth
407
674
  const chain = message.chain || [];
408
675
  if (chain.length >= LIMITS.max_depth) {
409
676
  log('WARN', `Dispatch blocked: max depth ${LIMITS.max_depth} reached (chain: ${chain.join('→')})`);
677
+ markTaskBlocked('max_depth_exceeded');
410
678
  return { success: false, error: 'max_depth_exceeded' };
411
679
  }
412
680
 
413
681
  // Anti-storm: check for cycles
414
682
  if (chain.includes(targetProject)) {
415
683
  log('WARN', `Dispatch blocked: cycle detected (${chain.join('→')}→${targetProject})`);
684
+ markTaskBlocked('cycle_detected');
416
685
  return { success: false, error: 'cycle_detected' };
417
686
  }
418
687
 
@@ -427,10 +696,12 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
427
696
  const toTarget = recent.filter(e => e.to === targetProject).length;
428
697
  if (toTarget >= LIMITS.max_per_hour_per_target) {
429
698
  log('WARN', `Dispatch blocked: rate limit to ${targetProject} (${toTarget}/${LIMITS.max_per_hour_per_target} per hour)`);
699
+ markTaskBlocked('rate_limit_target');
430
700
  return { success: false, error: 'rate_limit_target' };
431
701
  }
432
702
  if (recent.length >= LIMITS.max_total_per_hour) {
433
703
  log('WARN', `Dispatch blocked: total rate limit (${recent.length}/${LIMITS.max_total_per_hour} per hour)`);
704
+ markTaskBlocked('rate_limit_total');
434
705
  return { success: false, error: 'rate_limit_total' };
435
706
  }
436
707
  }
@@ -440,6 +711,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
440
711
 
441
712
  if (!_handleCommand) {
442
713
  log('WARN', 'Dispatch: handleCommand not yet bound, dropping task');
714
+ markTaskBlocked('handler_not_ready');
443
715
  return { success: false, error: 'handler_not_ready' };
444
716
  }
445
717
 
@@ -449,18 +721,52 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
449
721
  to: targetProject,
450
722
  type: message.type || 'task',
451
723
  priority: message.priority || 'normal',
452
- payload: message.payload || {},
724
+ payload,
453
725
  callback: message.callback || false,
454
726
  new_session: !!message.new_session,
455
727
  chain: [...chain, message.from || 'unknown'],
728
+ task_id: envelope ? envelope.task_id : null,
729
+ scope_id: envelope ? envelope.scope_id : null,
456
730
  created_at: new Date().toISOString(),
457
731
  };
458
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
+
459
763
  // Write to dispatch log for audit / rate-limiting
460
764
  if (!fs.existsSync(DISPATCH_DIR)) fs.mkdirSync(DISPATCH_DIR, { recursive: true });
461
765
  fs.appendFileSync(DISPATCH_LOG, JSON.stringify({ ...fullMsg, dispatched_at: new Date().toISOString() }) + '\n', 'utf8');
462
766
 
463
- 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');
464
770
 
465
771
  // Inject sender identity when dispatched by another agent (not directly from user)
466
772
  const userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
@@ -478,18 +784,35 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
478
784
  // Daemon sends the ack autonomously; Claude should just state its plan in the reply text.
479
785
  prompt = `[行为要求:回复开头用1-2句「计划:xxx」说明执行方案,再开始执行。不要调用 dispatch_to,daemon 会自动转发你的回复。]\n\n${prompt}`;
480
786
 
481
- // Prefer target's real Feishu chatId so dispatch reuses the existing session
482
- // (--resume, no CLAUDE.md re-read, no token waste). Fall back to _agent_* virtual
483
- // All dispatches use _agent_* virtual chatId to ensure a clean session with
484
- // 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.
485
791
  const forceNew = !!fullMsg.new_session;
486
- const dispatchChatId = `_agent_${targetProject}`;
792
+ const dispatchChatId = buildDispatchChatId(targetProject, envelope && envelope.scope_id);
487
793
  const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
488
794
  log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
489
795
 
796
+ let _taskFinalized = false;
490
797
  const outputHandler = (output) => {
491
798
  const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
492
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
+ }
493
816
  if (replyFn && outStr.trim().length > 2) {
494
817
  replyFn(outStr);
495
818
  } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
@@ -509,7 +832,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
509
832
  // If streamOptions provided, use real bot so output appears in target's Feishu channel.
510
833
  // Otherwise fall back to nullBot which captures output for replyFn.
511
834
  const nullBot = streamOptions?.bot && streamOptions?.chatId
512
- ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId)
835
+ ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler)
513
836
  : createNullBot(outputHandler);
514
837
  // Permission inheritance: if daemon runs with dangerously_skip_permissions, dispatched agents
515
838
  // inherit the same level — they need Write access for implementation tasks.
@@ -524,11 +847,19 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
524
847
  }
525
848
  }
526
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
+ }
527
854
  _handleCommand(nullBot, dispatchChatId, prompt, config, null, null, dispatchReadOnly).catch(e => {
528
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
+ }
529
860
  });
530
861
 
531
- return { success: true, id: fullMsg.id };
862
+ return { success: true, id: fullMsg.id, task_id: envelope ? envelope.task_id : null };
532
863
  }
533
864
 
534
865
  /**
@@ -757,8 +1088,8 @@ const {
757
1088
  */
758
1089
  function attachOrCreateSession(chatId, projCwd, name) {
759
1090
  const state = loadState();
760
- // Virtual agent chatIds (_agent_*) always get a fresh one-shot session.
761
- // 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.
762
1093
  const newSess = createSession(chatId, projCwd, name || '');
763
1094
  state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
764
1095
  saveState(state);
@@ -1034,6 +1365,8 @@ const { handleAdminCommand } = createAdminCommandHandler({
1034
1365
  dispatchTask,
1035
1366
  log,
1036
1367
  skillEvolution,
1368
+ taskBoard,
1369
+ taskEnvelope,
1037
1370
  });
1038
1371
 
1039
1372
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1289,7 +1622,19 @@ async function main() {
1289
1622
 
1290
1623
  // Config validation: warn on unknown/suspect fields
1291
1624
  const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
1292
- 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
+ ];
1293
1638
  const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
1294
1639
  for (const key of Object.keys(config)) {
1295
1640
  if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);