metame-cli 1.3.17 → 1.3.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/README.md CHANGED
@@ -399,6 +399,53 @@ Each step runs in the same Claude Code session. Step outputs automatically becom
399
399
  * `~/.metame/` directory set to mode 700
400
400
  * Bot tokens stored locally, never transmitted
401
401
 
402
+ ### Multi-Agent Projects — Context Isolation & Nickname Routing (v1.3.18)
403
+
404
+ Organize your work into named agents, each tied to a project directory. Switch between them instantly from your phone — no commands needed, just say their name.
405
+
406
+ **How it works:**
407
+
408
+ Each `project` entry in `daemon.yaml` defines an agent with a working directory, display name, notification color, and optional nicknames. When you send a message starting with a nickname, the daemon instantly switches to that project's last session — no Claude call, no token cost.
409
+
410
+ **Setup via conversation:**
411
+
412
+ The easiest way to add an agent is to tell the bot:
413
+
414
+ > *"Add a project called 'work' pointing to ~/my-project, nickname is '工作'"*
415
+
416
+ Or edit `~/.metame/daemon.yaml` directly:
417
+
418
+ ```yaml
419
+ projects:
420
+ my_agent:
421
+ name: "My Agent" # Display name in notifications
422
+ icon: "🤖" # Emoji shown in Feishu cards
423
+ color: "blue" # Feishu card color: blue|orange|green|purple|red|grey
424
+ cwd: "~/my-project" # Working directory for this agent
425
+ nicknames: [nickname1, nickname2] # Wake words (matched at message start)
426
+ heartbeat_tasks: [] # Scheduled tasks for this project (optional)
427
+ ```
428
+
429
+ **Available colors:** `blue` · `orange` · `green` · `purple` · `red` · `grey` · `turquoise`
430
+
431
+ **Phone commands:**
432
+
433
+ | Action | How |
434
+ |--------|-----|
435
+ | Switch agent | Send the nickname alone: `贾维斯` → `🤖 Jarvis 在线` |
436
+ | Switch + ask | `贾维斯, what's the status?` → switches then asks Claude |
437
+ | Pick from list | `/agent` → tap button to switch |
438
+ | Continue a reply | Reply to any bot message → session auto-restored |
439
+ | View all tasks | `/tasks` → grouped by project |
440
+ | Run a task | `/run <task-name>` |
441
+
442
+ **Nickname routing rules:**
443
+ - Matched at **message start only** — mentioning a nickname mid-sentence never triggers a switch
444
+ - Pure nickname (no content after) → instant switch, zero token cost, bypasses cooldown
445
+ - Nickname + content → switch then send content to Claude
446
+
447
+ **Heartbeat task notifications** arrive as colored Feishu cards — each project's color is distinct, so you can tell at a glance which agent sent the update.
448
+
402
449
  ### Provider Relay — Third-Party Model Support (v1.3.11)
403
450
 
404
451
  MetaMe supports any Anthropic-compatible API relay as a backend. This means you can route Claude Code through a third-party relay that maps `sonnet`/`opus`/`haiku` to any model (GPT-4, DeepSeek, Gemini, etc.) — MetaMe passes standard model names and the relay handles translation.
@@ -602,6 +649,7 @@ A: No. Your profile stays local at `~/.claude_profile.yaml`. MetaMe simply passe
602
649
 
603
650
  | Version | Highlights |
604
651
  |---------|------------|
652
+ | **v1.3.18** | **Multi-agent project isolation** — `projects` in `daemon.yaml` with per-project heartbeat tasks, Feishu colored cards per project, `/agent` picker button, nickname routing (say agent name to switch instantly), reply-to-message session restoration, fix `~` expansion in project cwd |
605
653
  | **v1.3.17** | **Windows support** (WSL one-command installer), `install-systemd` for Linux/WSL daemon auto-start. Fix onboarding (Genesis interview was never injected, CLAUDE.md accumulated across runs). Marker-based cleanup, unified protocols, `--append-system-prompt` guarantees interview activation, Feishu auto-fetch chat ID, full mobile permissions, fix `/publish` false-success, auto-restart daemon on script update |
606
654
  | **v1.3.16** | Git-based `/undo` (auto-checkpoint before each turn, `git reset --hard` rollback), `/nosleep` toggle (macOS caffeinate), custom provider model passthrough (`/model` accepts any name for non-anthropic providers), auto-fallback to anthropic/opus on provider failure, message queue works on Telegram (fire-and-forget poll loop), lazy background distill |
607
655
  | **v1.3.15** | Native Playwright MCP (browser automation for all users), `/list` interactive file browser with buttons, Feishu image download fix, Skill/MCP/Agent status push, hot restart reliability (single notification, no double instance) |
package/index.js CHANGED
@@ -28,9 +28,22 @@ if (!fs.existsSync(METAME_DIR)) {
28
28
  }
29
29
 
30
30
  // Auto-deploy bundled scripts to ~/.metame/
31
+ // IMPORTANT: daemon.yaml is USER CONFIG — never overwrite it. Only daemon-default.yaml (template) is synced.
31
32
  const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js', 'resolve-yaml.js', 'utils.js'];
32
33
  const scriptsDir = path.join(__dirname, 'scripts');
33
34
 
35
+ // Protect daemon.yaml: create backup before any sync operation
36
+ const DAEMON_YAML_BACKUP = path.join(METAME_DIR, 'daemon.yaml.bak');
37
+ try {
38
+ if (fs.existsSync(DAEMON_CONFIG_FILE)) {
39
+ const content = fs.readFileSync(DAEMON_CONFIG_FILE, 'utf8');
40
+ // Only backup if it has real config (not just the default template)
41
+ if (content.includes('enabled: true') || content.includes('bot_token:') && !content.includes('bot_token: null')) {
42
+ fs.copyFileSync(DAEMON_CONFIG_FILE, DAEMON_YAML_BACKUP);
43
+ }
44
+ }
45
+ } catch { /* non-fatal */ }
46
+
34
47
  let scriptsUpdated = false;
35
48
  for (const script of BUNDLED_SCRIPTS) {
36
49
  const src = path.join(scriptsDir, script);
@@ -63,7 +76,11 @@ if (scriptsUpdated) {
63
76
  }, 2000);
64
77
  const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
65
78
  setTimeout(() => {
66
- const bg = spawn(process.execPath, [DAEMON_SCRIPT], {
79
+ // Use caffeinate on macOS to prevent sleep while daemon is running
80
+ const isNotWindows = process.platform !== 'win32';
81
+ const cmd = isNotWindows ? 'caffeinate' : process.execPath;
82
+ const args = isNotWindows ? ['-i', process.execPath, DAEMON_SCRIPT] : [DAEMON_SCRIPT];
83
+ const bg = spawn(cmd, args, {
67
84
  detached: true,
68
85
  stdio: 'ignore',
69
86
  env: { ...process.env, HOME: HOME_DIR, METAME_ROOT: __dirname },
@@ -77,11 +94,27 @@ if (scriptsUpdated) {
77
94
  }
78
95
  }
79
96
 
80
- // Ensure daemon.yaml exists (copy from template if missing)
97
+ // Load daemon config for local launch flags
98
+ let daemonCfg = {};
99
+ try {
100
+ if (fs.existsSync(DAEMON_CONFIG_FILE)) {
101
+ const _yaml = require(path.join(__dirname, 'node_modules', 'js-yaml'));
102
+ const raw = _yaml.load(fs.readFileSync(DAEMON_CONFIG_FILE, 'utf8')) || {};
103
+ daemonCfg = raw.daemon || {};
104
+ }
105
+ } catch { /* non-fatal */ }
106
+
107
+ // Ensure daemon.yaml exists (restore backup or copy from template)
81
108
  if (!fs.existsSync(DAEMON_CONFIG_FILE)) {
82
- const daemonTemplate = path.join(scriptsDir, 'daemon-default.yaml');
83
- if (fs.existsSync(daemonTemplate)) {
84
- fs.copyFileSync(daemonTemplate, DAEMON_CONFIG_FILE);
109
+ if (fs.existsSync(DAEMON_YAML_BACKUP)) {
110
+ // Restore from backup — user had real config that was lost
111
+ fs.copyFileSync(DAEMON_YAML_BACKUP, DAEMON_CONFIG_FILE);
112
+ console.log('⚠️ daemon.yaml was missing — restored from backup.');
113
+ } else {
114
+ const daemonTemplate = path.join(scriptsDir, 'daemon-default.yaml');
115
+ if (fs.existsSync(daemonTemplate)) {
116
+ fs.copyFileSync(daemonTemplate, DAEMON_CONFIG_FILE);
117
+ }
85
118
  }
86
119
  }
87
120
 
@@ -161,6 +194,15 @@ function spawnDistillBackground() {
161
194
  const distillPath = path.join(METAME_DIR, 'distill.js');
162
195
  if (!fs.existsSync(distillPath)) return;
163
196
 
197
+ // Early exit if distillation already in progress (prevents duplicate spawns across terminals)
198
+ const lockFile = path.join(METAME_DIR, 'distill.lock');
199
+ if (fs.existsSync(lockFile)) {
200
+ try {
201
+ const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
202
+ if (lockAge < 120000) return;
203
+ } catch { /* stale lock, proceed */ }
204
+ }
205
+
164
206
  const hasSignals = shouldDistill();
165
207
  const bootstrap = needsBootstrap();
166
208
 
@@ -1086,6 +1128,8 @@ if (isDaemon) {
1086
1128
  <string>com.metame.daemon</string>
1087
1129
  <key>ProgramArguments</key>
1088
1130
  <array>
1131
+ <string>/usr/bin/caffeinate</string>
1132
+ <string>-i</string>
1089
1133
  <string>${nodePath}</string>
1090
1134
  <string>${DAEMON_SCRIPT}</string>
1091
1135
  </array>
@@ -1207,7 +1251,11 @@ WantedBy=default.target
1207
1251
  console.error("❌ daemon.js not found. Reinstall MetaMe.");
1208
1252
  process.exit(1);
1209
1253
  }
1210
- const bg = spawn(process.execPath, [DAEMON_SCRIPT], {
1254
+ // Use caffeinate on macOS/Linux to prevent sleep while daemon is running
1255
+ const isNotWindows = process.platform !== 'win32';
1256
+ const cmd = isNotWindows ? 'caffeinate' : process.execPath;
1257
+ const args = isNotWindows ? ['-i', process.execPath, DAEMON_SCRIPT] : [DAEMON_SCRIPT];
1258
+ const bg = spawn(cmd, args, {
1211
1259
  detached: true,
1212
1260
  stdio: 'ignore',
1213
1261
  env: { ...process.env, HOME: HOME_DIR, METAME_ROOT: __dirname },
@@ -1363,7 +1411,9 @@ if (isSync) {
1363
1411
 
1364
1412
  console.log(`\n🔄 Resuming session ${bestSession.id.slice(0, 8)}...\n`);
1365
1413
  const providerEnv = (() => { try { return require(path.join(__dirname, 'scripts', 'providers.js')).buildActiveEnv(); } catch { return {}; } })();
1366
- const syncChild = spawn('claude', ['--resume', bestSession.id], {
1414
+ const resumeArgs = ['--resume', bestSession.id];
1415
+ if (daemonCfg.dangerously_skip_permissions) resumeArgs.push('--dangerously-skip-permissions');
1416
+ const syncChild = spawn('claude', resumeArgs, {
1367
1417
  stdio: 'inherit',
1368
1418
  env: { ...process.env, ...providerEnv, METAME_ACTIVE_SESSION: 'true' }
1369
1419
  });
@@ -1398,6 +1448,9 @@ if (activeProviderName !== 'anthropic') {
1398
1448
 
1399
1449
  // Build launch args — inject system prompt for new users
1400
1450
  const launchArgs = process.argv.slice(2);
1451
+ if (daemonCfg.dangerously_skip_permissions && !launchArgs.includes('--dangerously-skip-permissions')) {
1452
+ launchArgs.push('--dangerously-skip-permissions');
1453
+ }
1401
1454
  if (!isKnownUser) {
1402
1455
  launchArgs.push(
1403
1456
  '--append-system-prompt',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.17",
3
+ "version": "1.3.18",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,8 +12,27 @@ feishu:
12
12
  app_secret: null
13
13
  allowed_chat_ids: []
14
14
 
15
+ projects:
16
+ # Per-project heartbeat tasks. Each project's tasks are isolated and
17
+ # notifications arrive as colored Feishu cards (visually distinct).
18
+ #
19
+ # example_project:
20
+ # name: "My Project"
21
+ # icon: "🎬"
22
+ # color: "orange" # blue|orange|green|red|grey|purple|turquoise
23
+ # heartbeat_tasks:
24
+ # - name: "daily-task"
25
+ # cwd: "~/AGI/MyProject"
26
+ # model: "sonnet"
27
+ # interval: "24h"
28
+ # notify: true
29
+ # enabled: true
30
+ # prompt: "..."
31
+ # allowedTools: [Read, Write, WebSearch]
32
+
15
33
  heartbeat:
16
34
  tasks: []
35
+ # Legacy flat tasks (no project isolation). New tasks should go under projects: above.
17
36
  # Examples — uncomment or add your own:
18
37
  #
19
38
  # Scheduled task (calls claude -p with your profile context):
package/scripts/daemon.js CHANGED
@@ -26,6 +26,37 @@ const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
26
26
  const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
27
27
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
28
28
 
29
+ // ---------------------------------------------------------
30
+ // SKILL ROUTING (keyword → /skillname prefix, like metame-desktop)
31
+ // ---------------------------------------------------------
32
+ const SKILL_ROUTES = [
33
+ { name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
34
+ { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
35
+ ];
36
+
37
+ function routeSkill(prompt) {
38
+ for (const r of SKILL_ROUTES) {
39
+ if (r.pattern.test(prompt)) return r.name;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // Agent nickname routing: matches "贾维斯" or "贾维斯,帮我..." at message start
45
+ // Returns { key, proj, rest } or null
46
+ function routeAgent(prompt, config) {
47
+ for (const [key, proj] of Object.entries((config && config.projects) || {})) {
48
+ if (!proj.cwd || !proj.nicknames) continue;
49
+ const nicks = Array.isArray(proj.nicknames) ? proj.nicknames : [proj.nicknames];
50
+ for (const nick of nicks) {
51
+ const re = new RegExp(`^${nick}[,,、\\s]*`, 'i');
52
+ if (re.test(prompt.trim())) {
53
+ return { key, proj, rest: prompt.trim().replace(re, '').trim() };
54
+ }
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
29
60
  const yaml = require('./resolve-yaml');
30
61
  const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
31
62
  if (!yaml) {
@@ -221,6 +252,11 @@ function checkPrecondition(task) {
221
252
  }
222
253
 
223
254
  function executeTask(task, config) {
255
+ if (task.enabled === false) {
256
+ log('INFO', `Skipping disabled task: ${task.name}`);
257
+ return { success: true, output: '(disabled)', skipped: true };
258
+ }
259
+
224
260
  const state = loadState();
225
261
 
226
262
  if (!checkBudget(config, state)) {
@@ -285,39 +321,81 @@ function executeTask(task, config) {
285
321
  }
286
322
  const fullPrompt = preamble + taskPrompt;
287
323
 
288
- const claudeArgs = ['-p', '--model', model];
324
+ const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
289
325
  for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
290
- log('INFO', `Executing task: ${task.name} (model: ${model})`);
326
+ // Auto-detect MCP config in task cwd or project directory
327
+ const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : undefined;
328
+ const mcpConfig = task.mcp_config
329
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
330
+ : cwd && fs.existsSync(path.join(cwd, '.mcp.json'))
331
+ ? path.join(cwd, '.mcp.json')
332
+ : null;
333
+ if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
334
+
335
+ // Persistent session: reuse same session across runs (for tasks like weekly-review)
336
+ if (task.persistent_session) {
337
+ const savedSessionId = state.tasks[task.name]?.session_id;
338
+ if (savedSessionId) {
339
+ claudeArgs.push('--resume', savedSessionId);
340
+ log('INFO', `Executing task: ${task.name} (model: ${model}, resuming session ${savedSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
341
+ } else {
342
+ const newSessionId = crypto.randomUUID();
343
+ claudeArgs.push('--session-id', newSessionId);
344
+ if (!state.tasks[task.name]) state.tasks[task.name] = {};
345
+ state.tasks[task.name].session_id = newSessionId;
346
+ saveState(state);
347
+ log('INFO', `Executing task: ${task.name} (model: ${model}, new session ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
348
+ }
349
+ } else {
350
+ log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
351
+ }
291
352
 
292
353
  try {
293
354
  const output = execFileSync('claude', claudeArgs, {
294
355
  input: fullPrompt,
295
356
  encoding: 'utf8',
296
- timeout: 120000, // 2 min timeout
297
- maxBuffer: 1024 * 1024,
298
- env: { ...process.env, ...getDaemonProviderEnv() },
357
+ timeout: task.timeout || 120000,
358
+ maxBuffer: 5 * 1024 * 1024,
359
+ ...(cwd && { cwd }),
360
+ env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
299
361
  }).trim();
300
362
 
301
363
  // Rough token estimate: ~4 chars per token for input + output
302
364
  const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
303
365
  recordTokens(state, estimatedTokens);
304
366
 
305
- // Record task result
367
+ // Record task result (preserve session_id for persistent sessions)
368
+ const prevSessionId = state.tasks[task.name]?.session_id;
306
369
  state.tasks[task.name] = {
307
370
  last_run: new Date().toISOString(),
308
371
  status: 'success',
309
372
  output_preview: output.slice(0, 200),
373
+ ...(prevSessionId && { session_id: prevSessionId }),
310
374
  };
311
375
  saveState(state);
312
376
 
313
377
  log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
314
378
  return { success: true, output, tokens: estimatedTokens };
315
379
  } catch (e) {
316
- log('ERROR', `Task ${task.name} failed: ${e.message}`);
380
+ const errMsg = e.message || '';
381
+ // If persistent session expired/not found, reset and let next run create fresh
382
+ if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
383
+ log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
384
+ state.tasks[task.name] = {
385
+ last_run: new Date().toISOString(),
386
+ status: 'session_reset',
387
+ error: 'Session expired, will retry with new session',
388
+ };
389
+ saveState(state);
390
+ return { success: false, error: 'session_expired', output: '' };
391
+ }
392
+ log('ERROR', `Task ${task.name} failed: ${errMsg}`);
393
+ const prevSid = state.tasks[task.name]?.session_id;
317
394
  state.tasks[task.name] = {
318
395
  last_run: new Date().toISOString(),
319
396
  status: 'error',
320
- error: e.message.slice(0, 200),
397
+ error: errMsg.slice(0, 200),
398
+ ...(prevSid && { session_id: prevSid }),
321
399
  };
322
400
  saveState(state);
323
401
  return { success: false, error: e.message, output: '' };
@@ -350,21 +428,28 @@ function executeWorkflow(task, config) {
350
428
  const outputs = [];
351
429
  let totalTokens = 0;
352
430
  const allowed = task.allowedTools || [];
431
+ // Auto-detect MCP config in task cwd
432
+ const mcpConfig = task.mcp_config
433
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
434
+ : fs.existsSync(path.join(cwd, '.mcp.json'))
435
+ ? path.join(cwd, '.mcp.json')
436
+ : null;
353
437
 
354
- log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}`);
438
+ log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
355
439
 
356
440
  for (let i = 0; i < steps.length; i++) {
357
441
  const step = steps[i];
358
442
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
359
443
  if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
360
- const args = ['-p', '--model', model];
444
+ const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
361
445
  for (const tool of allowed) args.push('--allowedTools', tool);
446
+ if (mcpConfig) args.push('--mcp-config', mcpConfig);
362
447
  args.push(i === 0 ? '--session-id' : '--resume', sessionId);
363
448
 
364
449
  log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
365
450
  try {
366
- const output = execSync(`claude ${args.join(' ')}`, {
367
- input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv() },
451
+ const output = execFileSync('claude', args, {
452
+ input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
368
453
  }).trim();
369
454
  const tk = Math.ceil((prompt.length + output.length) / 4);
370
455
  totalTokens += tk;
@@ -394,21 +479,35 @@ function executeWorkflow(task, config) {
394
479
  // HEARTBEAT SCHEDULER
395
480
  // ---------------------------------------------------------
396
481
  function startHeartbeat(config, notifyFn) {
397
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
482
+ const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
483
+ const projectTasks = [];
484
+ const legacyNames = new Set(legacyTasks.map(t => t.name));
485
+ for (const [key, proj] of Object.entries(config.projects || {})) {
486
+ for (const t of (proj.heartbeat_tasks || [])) {
487
+ if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
488
+ projectTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
489
+ }
490
+ }
491
+ const tasks = [...legacyTasks, ...projectTasks];
398
492
  if (tasks.length === 0) {
399
493
  log('INFO', 'No heartbeat tasks configured');
400
494
  return;
401
495
  }
402
496
 
497
+ const enabledTasks = tasks.filter(t => t.enabled !== false);
403
498
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
404
- log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${tasks.length} tasks)`);
499
+ log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
500
+
501
+ if (enabledTasks.length === 0) {
502
+ return;
503
+ }
405
504
 
406
505
  // Track next run times
407
506
  const nextRun = {};
408
507
  const now = Date.now();
409
508
  const state = loadState();
410
509
 
411
- for (const task of tasks) {
510
+ for (const task of enabledTasks) {
412
511
  const intervalSec = parseInterval(task.interval);
413
512
  const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
414
513
  if (lastRun) {
@@ -422,17 +521,18 @@ function startHeartbeat(config, notifyFn) {
422
521
 
423
522
  const timer = setInterval(() => {
424
523
  const currentTime = Date.now();
425
- for (const task of tasks) {
524
+ for (const task of enabledTasks) {
426
525
  if (currentTime >= (nextRun[task.name] || 0)) {
427
526
  const result = executeTask(task, config);
428
527
  const intervalSec = parseInterval(task.interval);
429
528
  nextRun[task.name] = currentTime + intervalSec * 1000;
430
529
 
431
530
  if (task.notify && notifyFn && !result.skipped) {
531
+ const proj = task._project || null;
432
532
  if (result.success) {
433
- notifyFn(`✅ *${task.name}* completed\n\n${result.output}`);
533
+ notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
434
534
  } else {
435
- notifyFn(`❌ *${task.name}* failed: ${result.error}`);
535
+ notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
436
536
  }
437
537
  }
438
538
  }
@@ -880,6 +980,81 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
880
980
  return;
881
981
  }
882
982
 
983
+ // /sessions — compact list, tap to see details, then tap to switch
984
+ if (text === '/sessions') {
985
+ const allSessions = listRecentSessions(15);
986
+ if (allSessions.length === 0) {
987
+ await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
988
+ return;
989
+ }
990
+ if (bot.sendButtons) {
991
+ const buttons = allSessions.map(s => {
992
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
993
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
994
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
995
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
996
+ const shortId = s.sessionId.slice(0, 6);
997
+ const name = s.customTitle || (s.summary || '').slice(0, 18) || '';
998
+ let label = `${ago} 📁${proj}`;
999
+ if (name) label += ` ${name}`;
1000
+ label += ` #${shortId}`;
1001
+ return [{ text: label, callback_data: `/sess ${s.sessionId}` }];
1002
+ });
1003
+ await bot.sendButtons(chatId, '📋 Tap a session to view details:', buttons);
1004
+ } else {
1005
+ let msg = '📋 Recent sessions:\n\n';
1006
+ allSessions.forEach((s, i) => {
1007
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
1008
+ const title = s.customTitle || s.summary || (s.firstPrompt || '').slice(0, 40) || '';
1009
+ const shortId = s.sessionId.slice(0, 8);
1010
+ msg += `${i + 1}. 📁${proj} | ${title}\n /resume ${shortId}\n`;
1011
+ });
1012
+ await bot.sendMessage(chatId, msg);
1013
+ }
1014
+ return;
1015
+ }
1016
+
1017
+ // /sess <id> — show session detail card with switch button
1018
+ if (text.startsWith('/sess ')) {
1019
+ const sid = text.slice(6).trim();
1020
+ const allSessions = listRecentSessions(50);
1021
+ const s = allSessions.find(x => x.sessionId === sid || x.sessionId.startsWith(sid));
1022
+ if (!s) {
1023
+ await bot.sendMessage(chatId, `Session not found: ${sid.slice(0, 8)}`);
1024
+ return;
1025
+ }
1026
+ const proj = s.projectPath || '~';
1027
+ const projName = path.basename(proj);
1028
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
1029
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
1030
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
1031
+ const title = s.customTitle || '';
1032
+ const summary = s.summary || '';
1033
+ const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
1034
+ const msgs = s.messageCount || '?';
1035
+
1036
+ let detail = `📋 Session Detail\n`;
1037
+ detail += `━━━━━━━━━━━━━━━━━━━━\n`;
1038
+ if (title) detail += `📝 Title: ${title}\n`;
1039
+ if (summary) detail += `💡 Summary: ${summary}\n`;
1040
+ detail += `📁 Project: ${projName}\n`;
1041
+ detail += `📂 Path: ${proj}\n`;
1042
+ detail += `💬 Messages: ${msgs}\n`;
1043
+ detail += `🕐 Last active: ${ago}\n`;
1044
+ detail += `🆔 ID: ${s.sessionId.slice(0, 8)}`;
1045
+ if (firstMsg && firstMsg !== summary) detail += `\n\n🗨️ First message:\n${firstMsg}`;
1046
+
1047
+ if (bot.sendButtons) {
1048
+ await bot.sendButtons(chatId, detail, [
1049
+ [{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
1050
+ [{ text: '⬅️ Back to list', callback_data: '/sessions' }],
1051
+ ]);
1052
+ } else {
1053
+ await bot.sendMessage(chatId, detail + `\n\n/resume ${s.sessionId.slice(0, 8)}`);
1054
+ }
1055
+ return;
1056
+ }
1057
+
883
1058
  if (text === '/resume' || text.startsWith('/resume ')) {
884
1059
  const arg = text.slice(7).trim();
885
1060
 
@@ -943,6 +1118,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
943
1118
  return;
944
1119
  }
945
1120
 
1121
+ if (text === '/agent') {
1122
+ const projects = config.projects || {};
1123
+ const entries = Object.entries(projects).filter(([, p]) => p.cwd);
1124
+ if (entries.length === 0) {
1125
+ await bot.sendMessage(chatId, 'No projects configured. Add projects with cwd to daemon.yaml.');
1126
+ return;
1127
+ }
1128
+ const currentSession = getSession(chatId);
1129
+ const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
1130
+ const buttons = entries.map(([key, p]) => {
1131
+ const projCwd = expandPath(p.cwd).replace(/^~/, HOME);
1132
+ const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
1133
+ return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
1134
+ });
1135
+ await bot.sendButtons(chatId, '切换对话对象', buttons);
1136
+ return;
1137
+ }
1138
+
946
1139
  if (text === '/cd' || text.startsWith('/cd ')) {
947
1140
  let newCwd = expandPath(text.slice(3).trim());
948
1141
  if (!newCwd) {
@@ -1082,14 +1275,28 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1082
1275
  }
1083
1276
 
1084
1277
  if (text === '/tasks') {
1085
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
1086
- if (tasks.length === 0) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
1087
- let msg = 'Heartbeat Tasks:\n';
1088
- for (const t of tasks) {
1089
- const ts = state.tasks[t.name] || {};
1090
- msg += `- ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1278
+ let msg = '';
1279
+ // Legacy flat tasks
1280
+ const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
1281
+ if (legacyTasks.length > 0) {
1282
+ msg += '📋 General:\n';
1283
+ for (const t of legacyTasks) {
1284
+ const ts = state.tasks[t.name] || {};
1285
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1286
+ }
1091
1287
  }
1092
- await bot.sendMessage(chatId, msg);
1288
+ // Project tasks grouped
1289
+ for (const [, proj] of Object.entries(config.projects || {})) {
1290
+ const pTasks = proj.heartbeat_tasks || [];
1291
+ if (pTasks.length === 0) continue;
1292
+ msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
1293
+ for (const t of pTasks) {
1294
+ const ts = state.tasks[t.name] || {};
1295
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1296
+ }
1297
+ }
1298
+ if (!msg) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
1299
+ await bot.sendMessage(chatId, msg.trim());
1093
1300
  return;
1094
1301
  }
1095
1302
 
@@ -1101,8 +1308,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1101
1308
  return;
1102
1309
  }
1103
1310
  const taskName = text.slice(5).trim();
1104
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
1105
- const task = tasks.find(t => t.name === taskName);
1311
+ const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
1312
+ for (const [key, proj] of Object.entries(config.projects || {})) {
1313
+ for (const t of (proj.heartbeat_tasks || [])) {
1314
+ allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
1315
+ }
1316
+ }
1317
+ const task = allRunTasks.find(t => t.name === taskName);
1106
1318
  if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
1107
1319
 
1108
1320
  // Script tasks: quick, run inline
@@ -1124,7 +1336,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1124
1336
  if (precheck.context) taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
1125
1337
  const fullPrompt = preamble + taskPrompt;
1126
1338
  const model = task.model || 'haiku';
1127
- const claudeArgs = ['-p', '--model', model];
1339
+ const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
1128
1340
  for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
1129
1341
 
1130
1342
  await bot.sendMessage(chatId, `Running: ${taskName} (${model})...`);
@@ -1189,6 +1401,106 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1189
1401
  return;
1190
1402
  }
1191
1403
 
1404
+ // /compact — compress current session context to save tokens
1405
+ if (text === '/compact') {
1406
+ const session = getSession(chatId);
1407
+ if (!session || !session.started) {
1408
+ await bot.sendMessage(chatId, '❌ No active session to compact.');
1409
+ return;
1410
+ }
1411
+ await bot.sendMessage(chatId, '🗜 Compacting session...');
1412
+
1413
+ // Step 1: Read conversation from JSONL (fast, no Claude needed)
1414
+ const jsonlPath = findSessionFile(session.id);
1415
+ if (!jsonlPath) {
1416
+ await bot.sendMessage(chatId, '❌ Session file not found.');
1417
+ return;
1418
+ }
1419
+ let messages = [];
1420
+ try {
1421
+ const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
1422
+ for (const line of lines) {
1423
+ try {
1424
+ const obj = JSON.parse(line);
1425
+ if (obj.type === 'user' || obj.type === 'assistant') {
1426
+ const msg = obj.message || {};
1427
+ const content = msg.content;
1428
+ let text_content = '';
1429
+ if (typeof content === 'string') {
1430
+ text_content = content;
1431
+ } else if (Array.isArray(content)) {
1432
+ text_content = content
1433
+ .filter(c => c.type === 'text')
1434
+ .map(c => c.text || '')
1435
+ .join(' ');
1436
+ }
1437
+ if (text_content.trim()) {
1438
+ messages.push({ role: obj.type, text: text_content.trim() });
1439
+ }
1440
+ }
1441
+ } catch { /* skip malformed lines */ }
1442
+ }
1443
+ } catch (e) {
1444
+ await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
1445
+ return;
1446
+ }
1447
+
1448
+ if (messages.length === 0) {
1449
+ await bot.sendMessage(chatId, '❌ No messages found in session.');
1450
+ return;
1451
+ }
1452
+
1453
+ // Step 2: Build a truncated conversation digest (keep under ~20k chars for haiku)
1454
+ const MAX_DIGEST = 20000;
1455
+ let digest = '';
1456
+ // Take messages from newest to oldest until we hit the limit
1457
+ for (let i = messages.length - 1; i >= 0; i--) {
1458
+ const m = messages[i];
1459
+ const prefix = m.role === 'user' ? 'USER' : 'ASSISTANT';
1460
+ const entry = `[${prefix}]: ${m.text.slice(0, 800)}\n\n`;
1461
+ if (digest.length + entry.length > MAX_DIGEST) break;
1462
+ digest = entry + digest;
1463
+ }
1464
+
1465
+ // Step 3: Summarize with haiku (new process, no --resume, fast)
1466
+ const daemonCfg = loadConfig().daemon || {};
1467
+ const compactArgs = ['-p', '--model', 'haiku', '--no-session-persistence'];
1468
+ if (daemonCfg.dangerously_skip_permissions) compactArgs.push('--dangerously-skip-permissions');
1469
+ const { output, error } = await spawnClaudeAsync(
1470
+ compactArgs,
1471
+ `Summarize the following conversation into a compact context document. Include: (1) what was being worked on, (2) key decisions made, (3) current state, (4) pending tasks. Be concise but preserve ALL important technical context (file names, function names, variable names, specific values). Output ONLY the summary.\n\n--- CONVERSATION ---\n${digest}`,
1472
+ session.cwd,
1473
+ 60000
1474
+ );
1475
+ if (error || !output) {
1476
+ await bot.sendMessage(chatId, `❌ Compact failed: ${error || 'no output'}`);
1477
+ return;
1478
+ }
1479
+
1480
+ // Step 4: Create new session with the summary
1481
+ const model = daemonCfg.model || 'opus';
1482
+ const oldName = getSessionName(session.id);
1483
+ const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '');
1484
+ const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
1485
+ if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
1486
+ const preamble = buildProfilePreamble();
1487
+ const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
1488
+ const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
1489
+ if (initErr) {
1490
+ await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
1491
+ return;
1492
+ }
1493
+ // Mark as started
1494
+ const state = loadState();
1495
+ if (state.sessions[chatId]) {
1496
+ state.sessions[chatId].started = true;
1497
+ saveState(state);
1498
+ }
1499
+ const tokenEst = Math.round(output.length / 3.5);
1500
+ await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
1501
+ return;
1502
+ }
1503
+
1192
1504
  // /publish <otp> — npm publish with OTP (zero latency, no Claude)
1193
1505
  if (text.startsWith('/publish ')) {
1194
1506
  const otp = text.slice(9).trim();
@@ -1595,8 +1907,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1595
1907
  '/last — 继续电脑上最近的对话',
1596
1908
  '/cd last — 切到电脑最近的项目目录',
1597
1909
  '',
1910
+ '🤖 Agent 切换:',
1911
+ '/agent — 选择对话的项目/Agent',
1912
+ '',
1598
1913
  '📂 Session 管理:',
1599
1914
  '/new [path] [name] — 新建会话',
1915
+ '/sessions — 浏览所有最近会话',
1600
1916
  '/resume [name] — 选择/恢复会话',
1601
1917
  '/name <name> — 命名当前会话',
1602
1918
  '/cd <path> — 切换工作目录',
@@ -1652,13 +1968,32 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1652
1968
  }, 5000);
1653
1969
  return;
1654
1970
  }
1971
+ // Nickname-only switch: bypass cooldown + budget (no Claude call)
1972
+ const quickAgent = routeAgent(text, config);
1973
+ if (quickAgent && !quickAgent.rest) {
1974
+ const { key, proj } = quickAgent;
1975
+ const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
1976
+ const st = loadState();
1977
+ const recentInDir = listRecentSessions(1, projCwd);
1978
+ if (recentInDir.length > 0 && recentInDir[0].sessionId) {
1979
+ st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
1980
+ } else {
1981
+ const newSess = createSession(chatId, projCwd, proj.name || key);
1982
+ st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
1983
+ }
1984
+ saveState(st);
1985
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
1986
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
1987
+ return;
1988
+ }
1989
+
1655
1990
  const cd = checkCooldown(chatId);
1656
1991
  if (!cd.ok) { await bot.sendMessage(chatId, `${cd.wait}s`); return; }
1657
1992
  if (!checkBudget(loadConfig(), loadState())) {
1658
1993
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
1659
1994
  return;
1660
1995
  }
1661
- await askClaude(bot, chatId, text);
1996
+ await askClaude(bot, chatId, text, config);
1662
1997
  }
1663
1998
 
1664
1999
  // ---------------------------------------------------------
@@ -1858,6 +2193,7 @@ function createSession(chatId, cwd, name) {
1858
2193
  saveState(state);
1859
2194
  invalidateSessionCache();
1860
2195
 
2196
+
1861
2197
  // If name provided, write to Claude's session file (same as /rename on desktop)
1862
2198
  if (name) {
1863
2199
  writeSessionName(sessionId, cwd || HOME, name);
@@ -1958,7 +2294,7 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1958
2294
  const child = spawn('claude', args, {
1959
2295
  cwd,
1960
2296
  stdio: ['pipe', 'pipe', 'pipe'],
1961
- env: { ...process.env, ...getActiveProviderEnv() },
2297
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
1962
2298
  });
1963
2299
 
1964
2300
  let stdout = '';
@@ -2133,7 +2469,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2133
2469
  const child = spawn('claude', streamArgs, {
2134
2470
  cwd,
2135
2471
  stdio: ['pipe', 'pipe', 'pipe'],
2136
- env: { ...process.env, ...getActiveProviderEnv() },
2472
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
2137
2473
  });
2138
2474
 
2139
2475
  // Track active process for /stop
@@ -2306,6 +2642,20 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2306
2642
 
2307
2643
  // Lazy distill: run distill.js in background on first message, then every 4 hours
2308
2644
  let _lastDistillTime = 0;
2645
+ // Track outbound message_id → session for reply-based session restoration.
2646
+ // Keeps last 200 entries to avoid unbounded growth.
2647
+ function trackMsgSession(messageId, session) {
2648
+ if (!messageId || !session || !session.id) return;
2649
+ const st = loadState();
2650
+ if (!st.msg_sessions) st.msg_sessions = {};
2651
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
2652
+ const keys = Object.keys(st.msg_sessions);
2653
+ if (keys.length > 200) {
2654
+ for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
2655
+ }
2656
+ saveState(st);
2657
+ }
2658
+
2309
2659
  function lazyDistill() {
2310
2660
  const now = Date.now();
2311
2661
  if (now - _lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
@@ -2326,7 +2676,7 @@ function lazyDistill() {
2326
2676
  * Shared ask logic — full Claude Code session (stateful, with tools)
2327
2677
  * Now uses spawn (async) instead of execSync to allow parallel requests.
2328
2678
  */
2329
- async function askClaude(bot, chatId, prompt) {
2679
+ async function askClaude(bot, chatId, prompt, config) {
2330
2680
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
2331
2681
  // Trigger background distill on first message / every 4h
2332
2682
  try { lazyDistill(); } catch { /* non-fatal */ }
@@ -2343,8 +2693,60 @@ async function askClaude(bot, chatId, prompt) {
2343
2693
  bot.sendTyping(chatId).catch(() => {});
2344
2694
  }, 4000);
2345
2695
 
2696
+ // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
2697
+ const agentMatch = routeAgent(prompt, config);
2698
+ if (agentMatch) {
2699
+ const { key, proj, rest } = agentMatch;
2700
+ const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
2701
+ const st = loadState();
2702
+ const recentInDir = listRecentSessions(1, projCwd);
2703
+ if (recentInDir.length > 0 && recentInDir[0].sessionId) {
2704
+ st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
2705
+ } else {
2706
+ const newSess = createSession(chatId, projCwd, proj.name || key);
2707
+ st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
2708
+ }
2709
+ saveState(st);
2710
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
2711
+ if (!rest) {
2712
+ // Pure nickname call — confirm switch and stop
2713
+ clearInterval(typingTimer);
2714
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
2715
+ return;
2716
+ }
2717
+ // Nickname + content — strip nickname, continue with rest as prompt
2718
+ prompt = rest;
2719
+ }
2720
+
2721
+ // Skill routing: detect skill first, then decide session
2722
+ const skill = routeSkill(prompt);
2723
+
2724
+ // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
2725
+ const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
2726
+
2346
2727
  let session = getSession(chatId);
2347
- if (!session) {
2728
+
2729
+ if (skill && PINNED_SKILL_SESSIONS.has(skill)) {
2730
+ // Use a dedicated long-lived session per skill
2731
+ const state = loadState();
2732
+ if (!state.pinned_sessions) state.pinned_sessions = {};
2733
+ const pinned = state.pinned_sessions[skill];
2734
+ if (pinned) {
2735
+ // Reuse existing pinned session
2736
+ state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
2737
+ saveState(state);
2738
+ session = state.sessions[chatId];
2739
+ log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
2740
+ } else {
2741
+ // First time — create session and pin it
2742
+ session = createSession(chatId, HOME, skill);
2743
+ const st2 = loadState();
2744
+ if (!st2.pinned_sessions) st2.pinned_sessions = {};
2745
+ st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
2746
+ saveState(st2);
2747
+ log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
2748
+ }
2749
+ } else if (!session) {
2348
2750
  // Auto-attach to most recent Claude session (unified session management)
2349
2751
  const recent = listRecentSessions(1);
2350
2752
  if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
@@ -2353,7 +2755,7 @@ async function askClaude(bot, chatId, prompt) {
2353
2755
  state.sessions[chatId] = {
2354
2756
  id: target.sessionId,
2355
2757
  cwd: target.projectPath,
2356
- started: true, // Already has history
2758
+ started: true,
2357
2759
  };
2358
2760
  saveState(state);
2359
2761
  session = state.sessions[chatId];
@@ -2365,20 +2767,16 @@ async function askClaude(bot, chatId, prompt) {
2365
2767
 
2366
2768
  // Build claude command
2367
2769
  const args = ['-p'];
2368
- // Model from daemon config (default: opus)
2369
2770
  const daemonCfg = loadConfig().daemon || {};
2370
2771
  const model = daemonCfg.model || 'opus';
2371
2772
  args.push('--model', model);
2372
- // Permission mode: full access (mobile users can't click "allow")
2373
2773
  if (daemonCfg.dangerously_skip_permissions) {
2374
2774
  args.push('--dangerously-skip-permissions');
2375
2775
  } else {
2376
- // Legacy: per-tool whitelist
2377
2776
  const sessionAllowed = daemonCfg.session_allowed_tools || [];
2378
2777
  for (const tool of sessionAllowed) args.push('--allowedTools', tool);
2379
2778
  }
2380
2779
  if (session.id === '__continue__') {
2381
- // /continue — resume most recent conversation in cwd
2382
2780
  args.push('--continue');
2383
2781
  } else if (session.started) {
2384
2782
  args.push('--resume', session.id);
@@ -2386,16 +2784,18 @@ async function askClaude(bot, chatId, prompt) {
2386
2784
  args.push('--session-id', session.id);
2387
2785
  }
2388
2786
 
2389
- // Append daemon context hint
2390
- const daemonHint = `\n\n[System hints - DO NOT mention these to user:
2787
+ // Inject daemon hints only on first message of a session
2788
+ const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
2391
2789
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
2392
2790
  2. File sending: User is on MOBILE. When they ask to see/download a file:
2393
2791
  - Just FIND the file path (use Glob/ls if needed)
2394
2792
  - Do NOT read or summarize the file content (wastes tokens)
2395
2793
  - Add at END of response: [[FILE:/absolute/path/to/file]]
2396
2794
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
2397
- - Multiple files: use multiple [[FILE:...]] tags]`;
2398
- const fullPrompt = prompt + daemonHint;
2795
+ - Multiple files: use multiple [[FILE:...]] tags]` : '';
2796
+
2797
+ const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
2798
+ const fullPrompt = routedPrompt + daemonHint;
2399
2799
 
2400
2800
  // Git checkpoint before Claude modifies files (for /undo)
2401
2801
  gitCheckpoint(session.cwd);
@@ -2419,6 +2819,16 @@ async function askClaude(bot, chatId, prompt) {
2419
2819
  bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2420
2820
  }
2421
2821
 
2822
+ // When Claude completes with no text output (pure tool work), send a done notice
2823
+ if (output === '' && !error) {
2824
+ const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
2825
+ const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
2826
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
2827
+ const wasNew = !session.started;
2828
+ if (wasNew) markSessionStarted(chatId);
2829
+ return;
2830
+ }
2831
+
2422
2832
  if (output) {
2423
2833
  // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
2424
2834
  const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
@@ -2453,7 +2863,8 @@ async function askClaude(bot, chatId, prompt) {
2453
2863
  const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
2454
2864
  const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
2455
2865
 
2456
- await bot.sendMarkdown(chatId, cleanOutput);
2866
+ const replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
2867
+ if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
2457
2868
 
2458
2869
  // Combine: marked files + auto-detected content files from Write operations
2459
2870
  const allFiles = new Set(markedFiles);
@@ -2604,6 +3015,18 @@ async function startFeishuBridge(config, executeTaskByName) {
2604
3015
  // Handle text message
2605
3016
  if (text) {
2606
3017
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
3018
+ // Reply-based session restoration: if user replied to a bot message,
3019
+ // restore the session that sent that message before processing.
3020
+ const parentId = event?.message?.parent_id;
3021
+ if (parentId) {
3022
+ const st = loadState();
3023
+ const mapped = st.msg_sessions && st.msg_sessions[parentId];
3024
+ if (mapped) {
3025
+ st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
3026
+ saveState(st);
3027
+ log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
3028
+ }
3029
+ }
2607
3030
  await handleCommand(bot, chatId, text, config, executeTaskByName);
2608
3031
  }
2609
3032
  });
@@ -2669,7 +3092,7 @@ async function main() {
2669
3092
  }
2670
3093
 
2671
3094
  // Config validation: warn on unknown/suspect fields
2672
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget'];
3095
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
2673
3096
  const KNOWN_DAEMON = ['model', 'log_max_size', 'heartbeat_check_interval', 'session_allowed_tools', 'dangerously_skip_permissions', 'cooldown_seconds'];
2674
3097
  const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
2675
3098
  for (const key of Object.keys(config)) {
@@ -2702,8 +3125,14 @@ async function main() {
2702
3125
 
2703
3126
  // Task executor lookup (always reads fresh config)
2704
3127
  function executeTaskByName(name) {
2705
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
2706
- const task = tasks.find(t => t.name === name);
3128
+ const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
3129
+ let task = legacy.find(t => t.name === name);
3130
+ if (!task) {
3131
+ for (const [key, proj] of Object.entries(config.projects || {})) {
3132
+ const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
3133
+ if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
3134
+ }
3135
+ }
2707
3136
  if (!task) return { success: false, error: `Task "${name}" not found` };
2708
3137
  return executeTask(task, config);
2709
3138
  }
@@ -2713,7 +3142,8 @@ async function main() {
2713
3142
  let feishuBridge = null;
2714
3143
 
2715
3144
  // Notification function (sends to all enabled channels)
2716
- const notifyFn = async (message) => {
3145
+ // project: optional { key, name, color, icon } — triggers colored card on Feishu
3146
+ const notifyFn = async (message, project = null) => {
2717
3147
  if (telegramBridge && telegramBridge.bot) {
2718
3148
  const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
2719
3149
  for (const chatId of tgIds) {
@@ -2725,7 +3155,17 @@ async function main() {
2725
3155
  if (feishuBridge && feishuBridge.bot) {
2726
3156
  const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
2727
3157
  for (const chatId of fsIds) {
2728
- try { await feishuBridge.bot.sendMessage(chatId, message); } catch (e) {
3158
+ try {
3159
+ if (project && feishuBridge.bot.sendCard) {
3160
+ await feishuBridge.bot.sendCard(chatId, {
3161
+ title: `${project.icon} ${project.name}`,
3162
+ body: message,
3163
+ color: project.color,
3164
+ });
3165
+ } else {
3166
+ await feishuBridge.bot.sendMessage(chatId, message);
3167
+ }
3168
+ } catch (e) {
2729
3169
  log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
2730
3170
  }
2731
3171
  }
@@ -2743,8 +3183,11 @@ async function main() {
2743
3183
  refreshLogMaxSize(config);
2744
3184
  if (heartbeatTimer) clearInterval(heartbeatTimer);
2745
3185
  heartbeatTimer = startHeartbeat(config, notifyFn);
2746
- log('INFO', `Config reloaded: ${(config.heartbeat && config.heartbeat.tasks || []).length} tasks`);
2747
- return { success: true, tasks: (config.heartbeat && config.heartbeat.tasks || []).length };
3186
+ const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
3187
+ const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
3188
+ const totalCount = legacyCount + projectCount;
3189
+ log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
3190
+ return { success: true, tasks: totalCount };
2748
3191
  }
2749
3192
  // Expose reloadConfig to handleCommand via closure
2750
3193
  global._metameReload = reloadConfig;
@@ -38,7 +38,7 @@ function createBot(config) {
38
38
  * Send a plain text message
39
39
  */
40
40
  async sendMessage(chatId, text) {
41
- await client.im.message.create({
41
+ const res = await client.im.message.create({
42
42
  params: { receive_id_type: 'chat_id' },
43
43
  data: {
44
44
  receive_id: chatId,
@@ -46,20 +46,91 @@ function createBot(config) {
46
46
  content: JSON.stringify({ text }),
47
47
  },
48
48
  });
49
+ // Return Telegram-compatible shape so daemon can edit it later
50
+ const msgId = res?.data?.message_id;
51
+ return msgId ? { message_id: msgId } : null;
52
+ },
53
+
54
+ async editMessage(chatId, messageId, text) {
55
+ try {
56
+ await client.im.message.patch({
57
+ path: { message_id: messageId },
58
+ data: { content: JSON.stringify({ text }) },
59
+ });
60
+ } catch { /* non-fatal */ }
49
61
  },
50
62
 
51
63
  /**
52
- * Send markdown (Feishu doesn't support raw markdown sends as text)
64
+ * Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
53
65
  */
54
66
  async sendMarkdown(chatId, markdown) {
55
- await client.im.message.create({
67
+ // Convert standard markdown → lark_md compatible format
68
+ let content = markdown
69
+ .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
70
+ .replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
71
+
72
+ // Split into chunks if too long (lark_md element limit ~4000 chars)
73
+ const MAX_CHUNK = 3800;
74
+ const chunks = [];
75
+ if (content.length <= MAX_CHUNK) {
76
+ chunks.push(content);
77
+ } else {
78
+ // Split on double newlines to avoid breaking mid-paragraph
79
+ const paragraphs = content.split(/\n\n/);
80
+ let buf = '';
81
+ for (const p of paragraphs) {
82
+ if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
83
+ chunks.push(buf);
84
+ buf = p;
85
+ } else {
86
+ buf = buf ? buf + '\n\n' + p : p;
87
+ }
88
+ }
89
+ if (buf) chunks.push(buf);
90
+ }
91
+
92
+ const elements = chunks.map(c => ({
93
+ tag: 'div',
94
+ text: { tag: 'lark_md', content: c },
95
+ }));
96
+
97
+ const card = {
98
+ config: { wide_screen_mode: true },
99
+ elements,
100
+ };
101
+
102
+ const res = await client.im.message.create({
56
103
  params: { receive_id_type: 'chat_id' },
57
104
  data: {
58
105
  receive_id: chatId,
59
- msg_type: 'text',
60
- content: JSON.stringify({ text: markdown }),
106
+ msg_type: 'interactive',
107
+ content: JSON.stringify(card),
61
108
  },
62
109
  });
110
+ const msgId = res?.data?.message_id;
111
+ return msgId ? { message_id: msgId } : null;
112
+ },
113
+
114
+ /**
115
+ * Send a colored interactive card (for project-tagged notifications)
116
+ * @param {string} chatId
117
+ * @param {string} title - card header text
118
+ * @param {string} body - card body (lark markdown)
119
+ * @param {string} color - header color: blue|orange|green|red|grey|purple|turquoise
120
+ */
121
+ async sendCard(chatId, { title, body, color = 'blue' }) {
122
+ const elements = body ? [{ tag: 'div', text: { tag: 'lark_md', content: body } }] : [];
123
+ const card = {
124
+ config: { wide_screen_mode: true },
125
+ header: { title: { tag: 'plain_text', content: title }, template: color },
126
+ elements,
127
+ };
128
+ const res = await client.im.message.create({
129
+ params: { receive_id_type: 'chat_id' },
130
+ data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
131
+ });
132
+ const msgId = res?.data?.message_id;
133
+ return msgId ? { message_id: msgId } : null;
63
134
  },
64
135
 
65
136
  /**
@@ -13,7 +13,6 @@ const path = require('path');
13
13
  const os = require('os');
14
14
 
15
15
  const BUFFER_FILE = path.join(os.homedir(), '.metame', 'raw_signals.jsonl');
16
- const MAX_BUFFER_LINES = 50; // Safety cap to avoid unbounded growth
17
16
 
18
17
  // === CONFIDENCE PATTERNS ===
19
18
 
@@ -110,11 +109,6 @@ process.stdin.on('end', () => {
110
109
 
111
110
  existingLines.push(JSON.stringify(entry));
112
111
 
113
- // Keep only the most recent entries (drop oldest)
114
- if (existingLines.length > MAX_BUFFER_LINES) {
115
- existingLines = existingLines.slice(-MAX_BUFFER_LINES);
116
- }
117
-
118
112
  fs.writeFileSync(BUFFER_FILE, existingLines.join('\n') + '\n');
119
113
 
120
114
  } catch {