metame-cli 1.4.19 → 1.4.21

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
@@ -32,7 +32,7 @@ npm install -g metame-cli && metame
32
32
 
33
33
  ---
34
34
 
35
- > ### 🚀 v1.4.18 — Multi-User ACL + Session Context Preview
35
+ > ### 🚀 v1.4.19 — Multi-User ACL + Session Context Preview
36
36
  >
37
37
  > - **Multi-user permission system**: role-based ACL (admin / member / stranger) — share your bots with teammates without giving them full access. Manage users with `/user` commands.
38
38
  > - **Session context preview**: `/resume` and `/sessions` now show the last message snippet so you know exactly what to pick up.
@@ -243,11 +243,11 @@ systemctl --user start metame
243
243
 
244
244
  **Create your first Agent:**
245
245
 
246
- 1. Create a group chat in Telegram/Feishu, add your bot
247
- 2. Send `/agent bind <name>` in the group (e.g. `/agent bind personal`)
248
- 3. Pick a working directory from the buttons, or type a path directly — non-existent directories are created automatically → done
246
+ 1. In any existing group, say: `Create an agent, directory ~/xxx, responsible for xxx`
247
+ 2. Bot replies: Agent created — **send `/activate` in your new group to bind it**
248
+ 3. Create a new group, add the bot, send `/activate` binding complete
249
249
 
250
- > Want more Agents? Repeat: new group → add bot → `/agent bind <name>`. Each group = independent AI workspace.
250
+ > Want more Agents? Repeat: create in any group → new target group → `/activate`. Each group = independent AI workspace.
251
251
 
252
252
  ---
253
253
 
@@ -274,23 +274,26 @@ MetaMe's design philosophy: **one folder = one agent.**
274
274
 
275
275
  Give an agent a directory, drop a `CLAUDE.md` inside describing its role, and you're done. The folder is the agent — it can be a code project, a blog repo, any workspace you already have.
276
276
 
277
- ### Option 1: Just say it (fastest)
277
+ ### Option 1: Just say it (recommended)
278
278
 
279
- No commands needed. Tell the bot what you want in plain language — MetaMe understands intent and acts:
279
+ No commands needed. Tell the bot what you want in plain language. **The agent is created without binding to the current group** send `/activate` in your new target group to complete the binding:
280
280
 
281
281
  ```
282
- You: Create an agent for this group, directory ~/projects/assistant
283
- Bot: ✅ Agent created and bound
284
- Name: assistant
282
+ You: Create an agent, directory ~/projects/assistant, responsible for writing and content
283
+ Bot: ✅ Agent「assistant」created
285
284
  Dir: ~/projects/assistant
285
+ 📝 CLAUDE.md written
286
286
 
287
- You: Change this agent's role to: a writing and content creation assistant
288
- Bot: ✅ Role definition updated in CLAUDE.md
287
+ Next: send /activate in your new group to bind
288
+
289
+ ── In the new group ──
289
290
 
290
- You: Bind an agent to ~/AGI/MyProject
291
- Bot: Agent bound
292
- Name: MyProject
293
- Dir: ~/AGI/MyProject
291
+ You: /activate
292
+ Bot: 🤖 assistant bound
293
+ Dir: ~/projects/assistant
294
+
295
+ You: Change this agent's role to: focused on Python backend development
296
+ Bot: ✅ Role definition updated in CLAUDE.md
294
297
 
295
298
  You: List all agents
296
299
  Bot: 📋 Agent list
@@ -299,21 +302,22 @@ Bot: 📋 Agent list
299
302
  ...
300
303
  ```
301
304
 
302
- Supported intents: create, bind, unbind, edit role, list — just say it naturally.
305
+ Supported intents: create, bind (`/agent bind`), unbind, edit role, list — just say it naturally.
303
306
 
304
- ### Option 2: Wizard commands
307
+ ### Option 2: Commands
305
308
 
306
309
  Use `/agent` commands in any Telegram/Feishu group:
307
310
 
308
311
  | Command | What it does |
309
312
  |---------|-------------|
310
- | `/agent new` | Step-by-step wizard: pick a directory name the agent describe its role. MetaMe writes the role into `CLAUDE.md` automatically. You can also type a path directly in chat — if it doesn't exist, MetaMe creates it for you. |
311
- | `/agent bind <name> [dir]` | Quick bind: register this group as a named agent, optionally set working directory. |
313
+ | `/activate` | In a new group, sends this to auto-bind the most recently created pending agent. |
314
+ | `/agent bind <name> [dir]` | Manual bind: register this group as a named agent. Works anytime no need to recreate if agent already exists. |
312
315
  | `/agent list` | Show all configured agents. |
313
316
  | `/agent edit` | Update the current agent's role description (rewrites its `CLAUDE.md` section). |
317
+ | `/agent unbind` | Remove this group's agent binding. |
314
318
  | `/agent reset` | Remove the current agent's role section. |
315
319
 
316
- You can tap a button to pick an existing directory, or type any path directly in chat. If the path doesn't exist, it's created automatically.
320
+ > **Binding protection**: Each group can only be bound to one agent. Existing bindings cannot be overwritten without explicit `force:true`.
317
321
 
318
322
  ### From config file (for power users)
319
323
 
@@ -359,6 +363,7 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
359
363
 
360
364
  | Command | Action |
361
365
  |---------|--------|
366
+ | `/continue` | Sync to computer's current work (session + directory) |
362
367
  | `/last` | Resume most recent session |
363
368
  | `/new` | Start new session (project picker) |
364
369
  | `/resume` | Pick from session list |
@@ -367,7 +372,8 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
367
372
  | `/undo <hash>` | Roll back to a specific git checkpoint |
368
373
  | `/list` | Browse & download project files |
369
374
  | `/model` | Switch model (sonnet/opus/haiku) |
370
- | `/agent bind <name> [dir]` | Register group as dedicated agent |
375
+ | `/activate` | Activate and bind the most recently created pending agent in a new group |
376
+ | `/agent bind <name> [dir]` | Manually register group as dedicated agent |
371
377
  | `/mac` | macOS control helper: permissions check/open + AppleScript/JXA execution |
372
378
  | `/sh <cmd>` | Raw shell — bypasses Claude |
373
379
  | `/memory` | Memory stats: fact count, session tags, DB size |
@@ -416,7 +422,7 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
416
422
  ## Security
417
423
 
418
424
  - All data stays on your machine. No cloud, no telemetry.
419
- - `allowed_chat_ids` whitelist — unauthorized users get a one-step `/agent bind` guide instead of silent rejection.
425
+ - `allowed_chat_ids` whitelist — new groups get a smart prompt: if a pending agent activation exists, they're guided to send `/activate`; otherwise they receive setup instructions.
420
426
  - `operator_ids` for shared groups — non-operators get read-only mode.
421
427
  - `~/.metame/` directory is mode 700.
422
428
  - Bot tokens stored locally, never transmitted.
@@ -427,7 +433,7 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
427
433
  |--------|-------|
428
434
  | Daemon memory (idle) | ~100 MB RSS — standard Node.js process baseline |
429
435
  | Daemon CPU (idle, between heartbeats) | ~0% — event-loop sleeping |
430
- | Cognitive profile injection | ~800 tokens/session (0.4% of 200k context) |
436
+ | Cognitive profile injection | ~600 tokens/session (0.3% of 200k context) |
431
437
  | Dispatch latency (Unix socket) | <100ms |
432
438
  | Memory consolidation (per session) | ~1,500–2,000 tokens input + ~50–300 tokens output (Haiku) |
433
439
  | Session summary (per session) | ~400–900 tokens input + ≤250 tokens output (Haiku) |
package/index.js CHANGED
@@ -30,7 +30,7 @@ if (!fs.existsSync(METAME_DIR)) {
30
30
  // Auto-deploy bundled scripts to ~/.metame/
31
31
  // IMPORTANT: daemon.yaml is USER CONFIG — never overwrite it. Only daemon-default.yaml (template) is synced.
32
32
  const scriptsDir = path.join(__dirname, 'scripts');
33
- const BUNDLED_BASE_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', 'skill-evolution.js', 'memory.js', 'memory-extract.js', 'memory-search.js', 'qmd-client.js', 'session-summarize.js', 'check-macos-control-capabilities.sh', 'usage-classifier.js', 'task-board.js'];
33
+ const BUNDLED_BASE_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', 'skill-evolution.js', 'memory.js', 'memory-extract.js', 'memory-search.js', 'memory-gc.js', 'qmd-client.js', 'session-summarize.js', 'check-macos-control-capabilities.sh', 'usage-classifier.js', 'task-board.js', 'memory-nightly-reflect.js', 'memory-index.js'];
34
34
  const DAEMON_MODULE_SCRIPTS = (() => {
35
35
  try {
36
36
  return fs.readdirSync(scriptsDir).filter((f) => /^daemon-[\w-]+\.js$/.test(f));
@@ -78,6 +78,44 @@ if (scriptsUpdated) {
78
78
  console.log('📦 Scripts synced to ~/.metame/ — daemon will auto-restart when idle.');
79
79
  }
80
80
 
81
+ // ---------------------------------------------------------
82
+ // Deploy bundled skills to ~/.claude/skills/
83
+ // Only installs if not already present — never overwrites user customizations.
84
+ // ---------------------------------------------------------
85
+ const CLAUDE_SKILLS_DIR = path.join(HOME_DIR, '.claude', 'skills');
86
+ const bundledSkillsDir = path.join(__dirname, 'skills');
87
+ if (fs.existsSync(bundledSkillsDir)) {
88
+ try {
89
+ if (!fs.existsSync(CLAUDE_SKILLS_DIR)) {
90
+ fs.mkdirSync(CLAUDE_SKILLS_DIR, { recursive: true });
91
+ }
92
+ const skillsInstalled = [];
93
+ for (const skillName of fs.readdirSync(bundledSkillsDir)) {
94
+ const srcSkill = path.join(bundledSkillsDir, skillName);
95
+ const destSkill = path.join(CLAUDE_SKILLS_DIR, skillName);
96
+ if (!fs.statSync(srcSkill).isDirectory()) continue;
97
+ if (fs.existsSync(destSkill)) continue; // already installed, respect user's version
98
+ // Copy skill directory recursively
99
+ const copyDir = (src, dest) => {
100
+ fs.mkdirSync(dest, { recursive: true });
101
+ for (const entry of fs.readdirSync(src)) {
102
+ const s = path.join(src, entry);
103
+ const d = path.join(dest, entry);
104
+ if (fs.statSync(s).isDirectory()) copyDir(s, d);
105
+ else fs.copyFileSync(s, d);
106
+ }
107
+ };
108
+ copyDir(srcSkill, destSkill);
109
+ skillsInstalled.push(skillName);
110
+ }
111
+ if (skillsInstalled.length > 0) {
112
+ console.log(`🧠 Skills installed: ${skillsInstalled.join(', ')}`);
113
+ }
114
+ } catch {
115
+ // Non-fatal
116
+ }
117
+ }
118
+
81
119
  // Load daemon config for local launch flags
82
120
  let daemonCfg = {};
83
121
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.19",
3
+ "version": "1.4.21",
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": {
@@ -26,6 +26,10 @@ function createAdminCommandHandler(deps) {
26
26
  skillEvolution,
27
27
  taskBoard,
28
28
  taskEnvelope,
29
+ getActiveProcesses,
30
+ getMessageQueue,
31
+ loadState,
32
+ saveState,
29
33
  } = deps;
30
34
 
31
35
  function resolveProjectKey(targetName, projects) {
@@ -102,7 +106,13 @@ function createAdminCommandHandler(deps) {
102
106
  if (fs.existsSync(BRAIN_FILE)) {
103
107
  const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
104
108
  if (doc.identity) msg += `\nProfile: ${doc.identity.nickname || 'unknown'}`;
105
- if (doc.context && doc.context.focus) msg += `\nFocus: ${doc.context.focus}`;
109
+ const nowPath = require('path').join(require('os').homedir(), '.metame', 'memory', 'NOW.md');
110
+ try {
111
+ if (fs.existsSync(nowPath)) {
112
+ const nowContent = fs.readFileSync(nowPath, 'utf8').trim().split('\n')[0];
113
+ if (nowContent) msg += `\nNOW: ${nowContent.slice(0, 80)}`;
114
+ }
115
+ } catch { /* ignore */ }
106
116
  }
107
117
  } catch { /* ignore */ }
108
118
  await bot.sendMessage(chatId, msg);
@@ -639,6 +649,57 @@ function createAdminCommandHandler(deps) {
639
649
  return { handled: true, config };
640
650
  }
641
651
 
652
+ // /recover — kill all stuck tasks and reset message queues
653
+ if (text === '/recover') {
654
+ const activeProcesses = getActiveProcesses ? getActiveProcesses() : null;
655
+ const messageQueue = getMessageQueue ? getMessageQueue() : null;
656
+ if (!activeProcesses) {
657
+ await bot.sendMessage(chatId, '❌ 无法访问任务状态');
658
+ return { handled: true, config };
659
+ }
660
+ const stuckChatIds = [...activeProcesses.keys()];
661
+ let killed = 0;
662
+ for (const cid of stuckChatIds) {
663
+ const proc = activeProcesses.get(cid);
664
+ if (proc && proc.child) {
665
+ try { process.kill(-proc.child.pid, 'SIGTERM'); } catch { try { proc.child.kill('SIGTERM'); } catch { } }
666
+ killed++;
667
+ }
668
+ activeProcesses.delete(cid);
669
+ if (messageQueue && messageQueue.has(cid)) {
670
+ const q = messageQueue.get(cid);
671
+ if (q && q.timer) clearTimeout(q.timer);
672
+ messageQueue.delete(cid);
673
+ }
674
+ }
675
+ // Clear stale sessions (started: false = never completed first message, likely locked)
676
+ try {
677
+ const state = loadState();
678
+ let cleared = 0;
679
+ for (const [cid, sess] of Object.entries(state.sessions || {})) {
680
+ if (sess && !sess.started) {
681
+ delete state.sessions[cid];
682
+ cleared++;
683
+ }
684
+ }
685
+ if (cleared > 0) saveState(state);
686
+ } catch { /* non-critical */ }
687
+ // SIGKILL stragglers after 3s grace period
688
+ if (killed > 0) {
689
+ setTimeout(() => {
690
+ for (const cid of stuckChatIds) {
691
+ // proc references are stale but child.pid is still valid for cleanup
692
+ try { const proc = activeProcesses.get(cid); if (proc && proc.child) process.kill(-proc.child.pid, 'SIGKILL'); } catch { }
693
+ }
694
+ }, 3000);
695
+ }
696
+ const summary = killed > 0
697
+ ? `✅ 已重置 ${killed} 个卡住的任务,可重新发送消息。`
698
+ : '✅ 当前没有卡住的任务。';
699
+ await bot.sendMessage(chatId, summary);
700
+ return { handled: true, config };
701
+ }
702
+
642
703
  // /doctor — diagnostics; /fix — restore backup; /reset — reset model to sonnet
643
704
  if (text === '/fix') {
644
705
  if (restoreConfig()) {
@@ -696,15 +757,36 @@ function createAdminCommandHandler(deps) {
696
757
  const hasBak = fs.existsSync(bakFile);
697
758
  checks.push(hasBak ? '✅ 有备份' : '⚠️ 无备份');
698
759
 
760
+ // Check for stuck tasks (only flag tasks running > 10 minutes as suspicious)
761
+ const activeProcesses = getActiveProcesses ? getActiveProcesses() : null;
762
+ let hasStuck = false;
763
+ if (activeProcesses && activeProcesses.size > 0) {
764
+ const now = Date.now();
765
+ const stuckThreshold = 10 * 60 * 1000; // 10 minutes
766
+ const entries = [...activeProcesses.entries()];
767
+ const stuckEntries = entries.filter(([, proc]) => proc && proc.startedAt && (now - proc.startedAt) > stuckThreshold);
768
+ if (stuckEntries.length > 0) {
769
+ const stuckList = stuckEntries.map(([cid, proc]) => `${cid.slice(-8)}(${Math.round((now - proc.startedAt) / 60000)}min)`).join(', ');
770
+ checks.push(`⚠️ ${stuckEntries.length} 个任务疑似卡住 (${stuckList})`);
771
+ hasStuck = true;
772
+ issues++;
773
+ } else {
774
+ checks.push(`✅ ${entries.length} 个任务正常运行中`);
775
+ }
776
+ } else {
777
+ checks.push('✅ 无运行中任务');
778
+ }
779
+
699
780
  let msg = `🏥 诊断\n${checks.join('\n')}`;
700
781
  if (issues > 0) {
701
782
  if (bot.sendButtons) {
702
783
  const buttons = [];
703
- if (hasBak) buttons.push([{ text: '🔧 恢复备份', callback_data: '/fix' }]);
704
- buttons.push([{ text: '🔄 重置opus', callback_data: '/reset' }]);
784
+ if (hasStuck) buttons.push([{ text: '🔧 一键重置卡住任务', callback_data: '/recover' }]);
785
+ if (hasBak) buttons.push([{ text: '📦 恢复配置备份', callback_data: '/fix' }]);
786
+ buttons.push([{ text: '🔄 重置模型 opus', callback_data: '/reset' }]);
705
787
  await bot.sendButtons(chatId, msg, buttons);
706
788
  } else {
707
- msg += '\n/fix 恢复备份 /reset 重置opus';
789
+ msg += '\n/recover 重置卡住任务 /fix 恢复备份 /reset 重置opus';
708
790
  await bot.sendMessage(chatId, msg);
709
791
  }
710
792
  } else {
@@ -21,6 +21,7 @@ function createAgentCommandHandler(deps) {
21
21
  getSessionRecentContext,
22
22
  pendingBinds,
23
23
  pendingAgentFlows,
24
+ pendingActivations,
24
25
  doBindAgent,
25
26
  mergeAgentRole,
26
27
  agentTools,
@@ -29,6 +30,30 @@ function createAgentCommandHandler(deps) {
29
30
  agentBindTtlMs,
30
31
  } = deps;
31
32
 
33
+ // Pending activations have no TTL — they persist until consumed.
34
+ // The creating chatId is stored to prevent self-activation.
35
+
36
+ function storePendingActivation(agentKey, agentName, cwd, createdByChatId) {
37
+ if (!pendingActivations) return;
38
+ pendingActivations.set(agentKey, {
39
+ agentKey, agentName, cwd,
40
+ createdByChatId: String(createdByChatId),
41
+ createdAt: Date.now(),
42
+ });
43
+ }
44
+
45
+ // Returns the latest pending activation, excluding the creating chat
46
+ function getLatestActivationForChat(chatId) {
47
+ if (!pendingActivations || pendingActivations.size === 0) return null;
48
+ const cid = String(chatId);
49
+ let latest = null;
50
+ for (const rec of pendingActivations.values()) {
51
+ if (rec.createdByChatId === cid) continue; // creating chat cannot self-activate
52
+ if (!latest || rec.createdAt > latest.createdAt) latest = rec;
53
+ }
54
+ return latest;
55
+ }
56
+
32
57
  function resolveTtl(valueOrGetter, fallbackMs) {
33
58
  const raw = typeof valueOrGetter === 'function' ? valueOrGetter() : valueOrGetter;
34
59
  const num = Number(raw);
@@ -136,9 +161,15 @@ function createAgentCommandHandler(deps) {
136
161
  return { ok: true, data: legacy };
137
162
  }
138
163
 
139
- async function createAgentViaUnifiedApi(chatId, name, dir, roleDesc) {
164
+ async function createAgentViaUnifiedApi(chatId, name, dir, roleDesc, opts = {}) {
165
+ // Default: skip binding the creating chat — let the target group activate via /activate
166
+ const { skipChatBinding = true } = opts;
140
167
  if (agentTools && typeof agentTools.createNewWorkspaceAgent === 'function') {
141
- return agentTools.createNewWorkspaceAgent(name, dir, roleDesc, chatId);
168
+ const res = await agentTools.createNewWorkspaceAgent(name, dir, roleDesc, chatId, { skipChatBinding });
169
+ if (res.ok && skipChatBinding && res.data && res.data.projectKey) {
170
+ storePendingActivation(res.data.projectKey, name, res.data.cwd, chatId);
171
+ }
172
+ return res;
142
173
  }
143
174
  const bound = await doBindAgent({ sendMessage: async () => {} }, chatId, name, dir);
144
175
  if (!bound || bound.ok === false) {
@@ -272,40 +303,7 @@ function createAgentCommandHandler(deps) {
272
303
  return true;
273
304
  }
274
305
 
275
- // /agent new wizard state machine (kept for command compatibility)
276
- {
277
- const flow = getFreshFlow(String(chatId));
278
- if (flow && flow.step === 'name' && text && !text.startsWith('/')) {
279
- flow.name = text.trim();
280
- flow.step = 'desc';
281
- setFlow(String(chatId), flow);
282
- await bot.sendMessage(chatId, `好的,Agent 名称是「${flow.name}」\n\n请描述这个 Agent 的角色和职责(用自然语言):`);
283
- return true;
284
- }
285
- if (flow && flow.step === 'desc' && text && !text.startsWith('/')) {
286
- pendingAgentFlows.delete(String(chatId));
287
- const { dir, name } = flow;
288
- const description = text.trim();
289
- await bot.sendMessage(chatId, `⏳ 正在配置 Agent「${name}」,稍等...`);
290
- const created = await createAgentViaUnifiedApi(chatId, name, dir, description);
291
- if (!created.ok) {
292
- await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${created.error}`);
293
- return true;
294
- }
295
- if (created.data && created.data.cwd && typeof attachOrCreateSession === 'function') {
296
- attachOrCreateSession(chatId, normalizeCwd(created.data.cwd), name || '');
297
- }
298
- const roleInfo = created.data.role || {};
299
- if (roleInfo.skipped) {
300
- await bot.sendMessage(chatId, '✅ Agent 创建成功');
301
- } else if (roleInfo.created) {
302
- await bot.sendMessage(chatId, '📝 已创建 CLAUDE.md 并写入角色定义');
303
- } else {
304
- await bot.sendMessage(chatId, '📝 已将角色定义合并进现有 CLAUDE.md');
305
- }
306
- return true;
307
- }
308
- }
306
+ // wizard state machine removed use natural language to create agents
309
307
 
310
308
  // /agent edit wait-input flow (kept for command compatibility)
311
309
  {
@@ -355,7 +353,7 @@ function createAgentCommandHandler(deps) {
355
353
  }
356
354
  const agents = res.data.agents || [];
357
355
  if (agents.length === 0) {
358
- await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 创建,或 /agent bind <名称> 绑定目录。');
356
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n用自然语言说"创建一个agent,目录是~/xxx",或 /agent bind <名称> <目录>。');
359
357
  return true;
360
358
  }
361
359
  const lines = ['📋 已配置的 Agent:', ''];
@@ -373,19 +371,12 @@ function createAgentCommandHandler(deps) {
373
371
  return true;
374
372
  }
375
373
 
376
- // /agent new (wizard)
377
- if (agentSub === 'new') {
378
- setFlow(String(chatId), { step: 'dir' });
379
- await sendBrowse(bot, chatId, 'agent-new', HOME, '步骤1/3:选择这个 Agent 的工作目录');
380
- return true;
381
- }
382
-
383
374
  // /agent edit [描述]
384
375
  if (agentSub === 'edit') {
385
376
  const cfg = loadConfig();
386
377
  const { boundProj } = getBoundProject(chatId, cfg);
387
378
  if (!boundProj || !boundProj.cwd) {
388
- await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind /agent new');
379
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先用自然语言创建 Agent 或 /agent bind <名称> <目录>');
389
380
  return true;
390
381
  }
391
382
  const cwd = normalizeCwd(boundProj.cwd);
@@ -432,7 +423,7 @@ function createAgentCommandHandler(deps) {
432
423
  const cfg = loadConfig();
433
424
  const { boundProj } = getBoundProject(chatId, cfg);
434
425
  if (!boundProj || !boundProj.cwd) {
435
- await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind /agent new');
426
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先用自然语言创建 Agent 或 /agent bind <名称> <目录>');
436
427
  return true;
437
428
  }
438
429
  const cwd = normalizeCwd(boundProj.cwd);
@@ -457,7 +448,7 @@ function createAgentCommandHandler(deps) {
457
448
  const projects = config.projects || {};
458
449
  const entries = Object.entries(projects).filter(([, p]) => p.cwd);
459
450
  if (entries.length === 0) {
460
- await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 新建,或 /agent bind <名称> 绑定目录。');
451
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n用自然语言说"创建一个agent,目录是~/xxx",或 /agent bind <名称> <目录>。');
461
452
  return true;
462
453
  }
463
454
  const currentSession = getSession(chatId);
@@ -472,6 +463,41 @@ function createAgentCommandHandler(deps) {
472
463
  }
473
464
  }
474
465
 
466
+ // /activate — bind this unbound chat to the most recently created pending agent
467
+ if (text === '/activate' || text.startsWith('/activate ')) {
468
+ const cfg = loadConfig();
469
+ const { boundKey } = getBoundProject(chatId, cfg);
470
+ if (boundKey) {
471
+ await bot.sendMessage(chatId, `此群已绑定到「${boundKey}」,无需激活。如需更换请先 /agent unbind`);
472
+ return true;
473
+ }
474
+ const activation = getLatestActivationForChat(chatId);
475
+ if (!activation) {
476
+ // Check if this chat was the creator (self-activate attempt)
477
+ if (pendingActivations) {
478
+ for (const rec of pendingActivations.values()) {
479
+ if (rec.createdByChatId === String(chatId)) {
480
+ await bot.sendMessage(chatId,
481
+ `❌ 不能在创建来源群激活。\n请在你新建的目标群里发送 \`/activate\`\n\n` +
482
+ `或在任意群用: \`/agent bind ${rec.agentName} ${rec.cwd}\``
483
+ );
484
+ return true;
485
+ }
486
+ }
487
+ }
488
+ // No pending activation at all — guide to manual bind
489
+ await bot.sendMessage(chatId,
490
+ '没有待激活的 Agent。\n\n如果已创建过 Agent,直接用:\n`/agent bind <名称> <目录>`\n即可绑定,不需要重新创建。'
491
+ );
492
+ return true;
493
+ }
494
+ const bindRes = await bindViaUnifiedApi(bot, chatId, activation.agentName, activation.cwd);
495
+ if (bindRes.ok) {
496
+ pendingActivations && pendingActivations.delete(activation.agentKey);
497
+ }
498
+ return true;
499
+ }
500
+
475
501
  // /agent-bind-dir <path>: internal callback for bind picker
476
502
  if (text.startsWith('/agent-bind-dir ')) {
477
503
  const dirPath = expandPath(text.slice(16).trim());
@@ -485,22 +511,6 @@ function createAgentCommandHandler(deps) {
485
511
  return true;
486
512
  }
487
513
 
488
- // /agent-dir <path>: internal callback for /agent new wizard
489
- if (text.startsWith('/agent-dir ')) {
490
- const dirPath = expandPath(text.slice(11).trim());
491
- const flow = getFreshFlow(String(chatId));
492
- if (!flow || flow.step !== 'dir') {
493
- await bot.sendMessage(chatId, '❌ 没有待完成的 /agent new,请重新发送 /agent new');
494
- return true;
495
- }
496
- flow.dir = dirPath;
497
- flow.step = 'name';
498
- setFlow(String(chatId), flow);
499
- const displayPath = dirPath.replace(HOME, '~');
500
- await bot.sendMessage(chatId, `✓ 已选择目录:${displayPath}\n\n步骤2/3:给这个 Agent 起个名字?`);
501
- return true;
502
- }
503
-
504
514
  return false;
505
515
  }
506
516
 
@@ -37,7 +37,7 @@ function createAgentTools(deps) {
37
37
  if (!cfg[adapterKey].chat_agent_map) cfg[adapterKey].chat_agent_map = {};
38
38
  }
39
39
 
40
- async function bindAgentToChat(chatId, agentName, workspaceDir) {
40
+ async function bindAgentToChat(chatId, agentName, workspaceDir, { force = false } = {}) {
41
41
  try {
42
42
  const safeName = sanitizeText(agentName, 120);
43
43
  if (!safeName) return { ok: false, error: 'agentName is required' };
@@ -64,6 +64,16 @@ function createAgentTools(deps) {
64
64
  return { ok: false, error: `workspaceDir is not a directory: ${resolvedDir}` };
65
65
  }
66
66
 
67
+ // Overwrite protection: reject if chat is already bound to a different agent
68
+ const existingKey = cfg[adapterKey].chat_agent_map[String(chatId)];
69
+ if (existingKey && existingKey !== projectKey && !force) {
70
+ return {
71
+ ok: false,
72
+ error: `此群已绑定到 "${existingKey}",如需覆盖请使用 force:true`,
73
+ data: { existingKey },
74
+ };
75
+ }
76
+
67
77
  const idVal = typeof chatId === 'number' ? chatId : String(chatId);
68
78
  if (!cfg[adapterKey].allowed_chat_ids.includes(idVal)) cfg[adapterKey].allowed_chat_ids.push(idVal);
69
79
 
@@ -165,30 +175,57 @@ ${safeDelta}
165
175
  }
166
176
  }
167
177
 
168
- async function createNewWorkspaceAgent(agentName, workspaceDir, roleDescription, chatId) {
169
- const bindResult = await bindAgentToChat(chatId, agentName, workspaceDir);
170
- if (!bindResult.ok) return bindResult;
178
+ async function createNewWorkspaceAgent(agentName, workspaceDir, roleDescription, chatId, { skipChatBinding = false } = {}) {
179
+ let bindData;
180
+
181
+ if (skipChatBinding) {
182
+ // Create the project entry without touching chat_agent_map
183
+ const safeName = sanitizeText(agentName, 120);
184
+ if (!safeName) return { ok: false, error: 'agentName is required' };
185
+ const resolvedDir = resolveWorkspaceDir(workspaceDir);
186
+ if (!resolvedDir) return { ok: false, error: 'workspaceDir is required' };
187
+ if (!fs.existsSync(resolvedDir) || !fs.statSync(resolvedDir).isDirectory()) {
188
+ return { ok: false, error: `workspaceDir not found or not a directory: ${resolvedDir}` };
189
+ }
190
+ const cfg = loadConfig();
191
+ if (!cfg.projects) cfg.projects = {};
192
+ const projectKey = toProjectKey(safeName, chatId);
193
+ const existed = !!cfg.projects[projectKey];
194
+ if (!existed) {
195
+ cfg.projects[projectKey] = { name: safeName, cwd: resolvedDir, nicknames: [safeName] };
196
+ writeConfigSafe(cfg);
197
+ backupConfig();
198
+ }
199
+ bindData = {
200
+ projectKey,
201
+ cwd: resolvedDir,
202
+ isNewProject: !existed,
203
+ chatId: null, // not bound to any chat
204
+ project: cfg.projects[projectKey],
205
+ };
206
+ } else {
207
+ const bindResult = await bindAgentToChat(chatId, agentName, workspaceDir);
208
+ if (!bindResult.ok) return bindResult;
209
+ bindData = bindResult.data;
210
+ }
171
211
 
172
212
  const roleText = sanitizeText(roleDescription, 1200);
173
213
  if (!roleText) {
174
- return { ok: true, data: { ...bindResult.data, role: { skipped: true } } };
214
+ return { ok: true, data: { ...bindData, role: { skipped: true } } };
175
215
  }
176
216
 
177
- const roleResult = await editAgentRoleDefinition(bindResult.data.cwd, roleText);
217
+ const roleResult = await editAgentRoleDefinition(bindData.cwd, roleText);
178
218
  if (!roleResult.ok) {
179
219
  return {
180
220
  ok: false,
181
- error: `agent bound but role update failed: ${roleResult.error}`,
182
- data: { ...bindResult.data, roleError: roleResult.error },
221
+ error: `agent created but role update failed: ${roleResult.error}`,
222
+ data: { ...bindData, roleError: roleResult.error },
183
223
  };
184
224
  }
185
225
 
186
226
  return {
187
227
  ok: true,
188
- data: {
189
- ...bindResult.data,
190
- role: roleResult.data,
191
- },
228
+ data: { ...bindData, role: roleResult.data },
192
229
  };
193
230
  }
194
231