metame-cli 1.4.18 → 1.4.19

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
@@ -14,25 +14,31 @@
14
14
  <a href="./README.md">English</a> | <a href="./README中文版.md">中文</a>
15
15
  </p>
16
16
 
17
- > **Claude Code that knows you — and works from your phone.**
17
+ > **Your digital twin. Lives on your Mac.**
18
18
 
19
- MetaMe turns Claude Code into a persistent AI that remembers how you think, runs on your Mac 24/7, and takes commands from your phone via Telegram or Feishu.
19
+ MetaMe is an AI that lives on your machine remembers how you think, stays online 24/7, and takes commands from your phone via Telegram or Feishu. Not in the cloud. In your computer.
20
20
 
21
- One command. No cloud. Your machine, your data.
21
+ No cloud. Your machine, your data.
22
22
 
23
+ **macOS / Linux / Windows WSL — same command:**
24
+ ```bash
25
+ curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
26
+ ```
27
+
28
+ **Already have Node.js ≥ 18:**
23
29
  ```bash
24
30
  npm install -g metame-cli && metame
25
31
  ```
26
32
 
27
33
  ---
28
34
 
29
- > ### 🚀 v1.4.0Layered Memory Architecture
35
+ > ### 🚀 v1.4.18Multi-User ACL + Session Context Preview
30
36
  >
31
- > MetaMe now has a **three-layer memory system** that works completely in the background:
32
- > - **Long-term facts** extracted from every session, recalled semantically on demand
33
- > - **Session summary cache** when you resume after a 2h+ gap, MetaMe injects what you were last working on
34
- > - **Automatic session tagging** every conversation is indexed by topic, enabling future session routing
35
- > - **Unix Socket IPC** dispatch latency dropped from ~60s to <100ms
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
+ > - **Session context preview**: `/resume` and `/sessions` now show the last message snippet so you know exactly what to pick up.
39
+ > - **Team Task protocol**: multi-agent task board for cross-agent collaboration. Agents can dispatch and track tasks across workspaces.
40
+ > - **Layered Memory Architecture**: three-layer memory (long-term facts, session summaries, session index) all automatic.
41
+ > - **Unix Socket IPC**: dispatch latency <100ms.
36
42
  >
37
43
  > Zero configuration. It just works.
38
44
 
@@ -151,7 +157,21 @@ Chain skills into multi-step workflows — research → write → publish — fu
151
157
  prompt: "Publish it"
152
158
  ```
153
159
 
154
- Task options: `interval` (every N seconds/minutes/hours/days), `at` (fixed local `HH:MM`), `days` (optional day filter), `require_idle` (defer when you're active, retry on next heartbeat tick), `precondition` (shell guard — skip if false, zero tokens), `notify` (push result to phone), `model`, `cwd`, `allowedTools`, `timeout`.
160
+ **Task options:**
161
+
162
+ | Option | Description |
163
+ |--------|-------------|
164
+ | `at` | Fixed-time trigger, e.g. `"09:30"` (local time) |
165
+ | `days` | Day filter, e.g. `"weekdays"`, `[mon, wed, fri]` |
166
+ | `interval` | Interval trigger, e.g. `"4h"`, `"30m"` |
167
+ | `require_idle` | Skip if you're active; retry on next heartbeat tick |
168
+ | `precondition` | Shell guard — skip task if command returns non-zero (zero tokens consumed) |
169
+ | `notify` | Push result to phone when done |
170
+ | `model` | Override model, e.g. `"sonnet"`, `"haiku"` |
171
+ | `cwd` | Working directory for the task |
172
+ | `timeout` | Max run time |
173
+
174
+ > **Scheduled tasks require system registration.** Run `metame daemon install-launchd` and tasks fire on schedule even with the screen locked or the lid closed — as long as the Mac is on.
155
175
 
156
176
  ### 5. Skills That Evolve Themselves
157
177
 
@@ -173,6 +193,13 @@ Task fails → skill-scout finds a skill → installs → retries → succeeds
173
193
 
174
194
  ## Quick Start
175
195
 
196
+ **macOS / Linux / Windows WSL:**
197
+ ```bash
198
+ curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
199
+ ```
200
+ > Same command everywhere. The script detects your OS and uses Homebrew (macOS) or apt/dnf/pacman (Linux/WSL) to install Node.js automatically.
201
+
202
+ **Already have Node.js ≥ 18:**
176
203
  ```bash
177
204
  npm install -g metame-cli && metame
178
205
  ```
@@ -184,7 +211,35 @@ npm install -g metame-cli && metame
184
211
  | 1. Install & profile | `metame` | First run: cognitive interview → builds `~/.claude_profile.yaml` |
185
212
  | 2. Connect phone | Follow the setup wizard | Bot token + app credentials → `~/.metame/daemon.yaml` |
186
213
  | 3. Start daemon | `metame start` | Background daemon launches, bot goes online |
187
- | 4. Auto-start | `metame daemon install-launchd` | Survives reboot + crash recovery |
214
+ | 4. Register with system | macOS: `metame daemon install-launchd` · WSL/Linux: see below | Always-on, crash recovery |
215
+
216
+ > **What does system registration mean?**
217
+ > Once registered, MetaMe runs in the background automatically — screen locked, lid closed, woken from sleep — as long as the machine is on. Scheduled tasks fire on time. No terminal window needed.
218
+
219
+ **WSL2 / Linux — register with systemd:**
220
+
221
+ ```bash
222
+ cat > ~/.config/systemd/user/metame.service << 'EOF'
223
+ [Unit]
224
+ Description=MetaMe Daemon
225
+ After=network.target
226
+
227
+ [Service]
228
+ ExecStart=/usr/bin/env metame start
229
+ Restart=on-failure
230
+ RestartSec=5
231
+
232
+ [Install]
233
+ WantedBy=default.target
234
+ EOF
235
+
236
+ systemctl --user enable metame
237
+ systemctl --user start metame
238
+ ```
239
+
240
+ > WSL2 requires systemd enabled first: add `[boot]\nsystemd=true` to `/etc/wsl.conf`, then restart WSL.
241
+
242
+ > **WSL limitation:** `/mac` commands (macOS AppleScript/JXA automation) are not available.
188
243
 
189
244
  **Create your first Agent:**
190
245
 
@@ -209,15 +264,46 @@ npm install -g metame-cli && metame
209
264
  | **Browser Automation** | Built-in Playwright MCP. Browser control out of the box for every user. |
210
265
  | **Provider Relay** | Route through any Anthropic-compatible API. Use GPT-4, DeepSeek, Gemini — zero config file mutation. |
211
266
  | **Metacognition** | Detects behavioral patterns (decision style, comfort zones, goal drift) and injects mirror observations. Zero extra API cost. |
267
+ | **Multi-User ACL** | Role-based permission system (admin / member / stranger). Share bots with teammates safely. Dynamic user management via `/user` commands with hot-reload config. |
268
+ | **Team Task** | Multi-agent task board for cross-agent collaboration. Agents can create, assign, and track tasks across workspaces. N-agent session scoping for parallel team workflows. |
212
269
  | **Emergency Tools** | `/doctor` diagnostics, `/mac` macOS control helpers, `/sh` raw shell, `/fix` config restore, `/undo` git-based rollback. |
213
270
 
214
271
  ## Defining Your Agents
215
272
 
216
- Agent configs live in `~/.metame/daemon.yaml` local only, never uploaded to npm or Git.
273
+ MetaMe's design philosophy: **one folder = one agent.**
274
+
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
+
277
+ ### Option 1: Just say it (fastest)
278
+
279
+ No commands needed. Tell the bot what you want in plain language — MetaMe understands intent and acts:
280
+
281
+ ```
282
+ You: Create an agent for this group, directory ~/projects/assistant
283
+ Bot: ✅ Agent created and bound
284
+ Name: assistant
285
+ Dir: ~/projects/assistant
286
+
287
+ You: Change this agent's role to: a writing and content creation assistant
288
+ Bot: ✅ Role definition updated in CLAUDE.md
289
+
290
+ You: Bind an agent to ~/AGI/MyProject
291
+ Bot: ✅ Agent bound
292
+ Name: MyProject
293
+ Dir: ~/AGI/MyProject
294
+
295
+ You: List all agents
296
+ Bot: 📋 Agent list
297
+ 🤖 assistant ◀ current
298
+ Dir: ~/projects/assistant
299
+ ...
300
+ ```
301
+
302
+ Supported intents: create, bind, unbind, edit role, list — just say it naturally.
217
303
 
218
- ### From your phone (recommended)
304
+ ### Option 2: Wizard commands
219
305
 
220
- The easiest way. Open any Telegram/Feishu group and use the `/agent` wizard:
306
+ Use `/agent` commands in any Telegram/Feishu group:
221
307
 
222
308
  | Command | What it does |
223
309
  |---------|-------------|
@@ -227,21 +313,7 @@ The easiest way. Open any Telegram/Feishu group and use the `/agent` wizard:
227
313
  | `/agent edit` | Update the current agent's role description (rewrites its `CLAUDE.md` section). |
228
314
  | `/agent reset` | Remove the current agent's role section. |
229
315
 
230
- Example flow:
231
- ```
232
- You: /agent new
233
- Bot: Please select a working directory:
234
- 📁 ~/AGI 📁 ~/projects 📁 ~/Desktop
235
- You: ~/AGI/MyProject/NewDir
236
- Bot: ✅ 已新建目录:~/AGI/MyProject/NewDir
237
- What should we name this agent?
238
- You: 小美
239
- Bot: Describe 小美's role and responsibilities:
240
- You: Personal assistant. Manages my calendar, drafts messages, and tracks todos.
241
- Bot: ✅ Agent「小美」created. CLAUDE.md updated with role definition.
242
- ```
243
-
244
- 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. All entry points (`/agent new` wizard and `/agent bind`) validate that the directory is real before saving.
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.
245
317
 
246
318
  ### From config file (for power users)
247
319
 
@@ -301,6 +373,15 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
301
373
  | `/memory` | Memory stats: fact count, session tags, DB size |
302
374
  | `/memory <keyword>` | Search long-term facts by keyword |
303
375
  | `/doctor` | Interactive diagnostics |
376
+ | `/user add <open_id>` | Add a user (admin only) |
377
+ | `/user role <open_id> <admin\|member>` | Set user role |
378
+ | `/user list` | List all configured users |
379
+ | `/user remove <open_id>` | Remove a user |
380
+ | `/sessions` | Browse recent sessions with last message preview |
381
+ | `/teamtask create <agent> <goal>` | Create a cross-agent collaboration task |
382
+ | `/teamtask` | List recent TeamTasks (last 10) |
383
+ | `/teamtask <task_id>` | View task detail |
384
+ | `/teamtask resume <task_id>` | Resume a task |
304
385
 
305
386
  ## How It Works
306
387
 
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'];
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'];
34
34
  const DAEMON_MODULE_SCRIPTS = (() => {
35
35
  try {
36
36
  return fs.readdirSync(scriptsDir).filter((f) => /^daemon-[\w-]+\.js$/.test(f));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.18",
3
+ "version": "1.4.19",
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": {
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "test": "node --test scripts/*.test.js",
15
15
  "start": "node index.js",
16
- "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/daemon-agent-commands.js scripts/daemon-session-commands.js scripts/daemon-admin-commands.js scripts/daemon-exec-commands.js scripts/daemon-ops-commands.js scripts/daemon-session-store.js scripts/daemon-checkpoints.js scripts/daemon-bridges.js scripts/daemon-file-browser.js scripts/daemon-runtime-lifecycle.js scripts/daemon-notify.js scripts/daemon-claude-engine.js scripts/daemon-command-router.js scripts/daemon-agent-tools.js scripts/daemon-task-scheduler.js scripts/daemon-task-envelope.js scripts/task-board.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js scripts/usage-classifier.js scripts/resolve-yaml.js scripts/memory.js scripts/memory-extract.js scripts/qmd-client.js scripts/session-summarize.js scripts/session-analytics.js scripts/skill-evolution.js scripts/check-macos-control-capabilities.sh plugin/scripts/ && echo '✅ Plugin scripts synced'",
16
+ "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/daemon-agent-commands.js scripts/daemon-session-commands.js scripts/daemon-admin-commands.js scripts/daemon-exec-commands.js scripts/daemon-ops-commands.js scripts/daemon-session-store.js scripts/daemon-checkpoints.js scripts/daemon-bridges.js scripts/daemon-file-browser.js scripts/daemon-runtime-lifecycle.js scripts/daemon-notify.js scripts/daemon-claude-engine.js scripts/daemon-command-router.js scripts/daemon-user-acl.js scripts/daemon-agent-tools.js scripts/daemon-task-scheduler.js scripts/daemon-task-envelope.js scripts/task-board.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js scripts/usage-classifier.js scripts/resolve-yaml.js scripts/memory.js scripts/memory-extract.js scripts/qmd-client.js scripts/session-summarize.js scripts/session-analytics.js scripts/skill-evolution.js scripts/check-macos-control-capabilities.sh plugin/scripts/ && echo '✅ Plugin scripts synced'",
17
17
  "restart:daemon": "node index.js stop 2>/dev/null; sleep 1; node index.js start 2>/dev/null || echo '⚠️ Daemon not running or restart failed'",
18
18
  "precommit": "npm run sync:plugin && npm run restart:daemon"
19
19
  },
@@ -18,6 +18,7 @@ function createAgentCommandHandler(deps) {
18
18
  sessionLabel,
19
19
  loadSessionTags,
20
20
  sessionRichLabel,
21
+ getSessionRecentContext,
21
22
  pendingBinds,
22
23
  pendingAgentFlows,
23
24
  doBindAgent,
@@ -249,7 +250,25 @@ function createAgentCommandHandler(deps) {
249
250
  saveState(state2);
250
251
  const name = fullMatch.customTitle;
251
252
  const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || sessionId.slice(0, 8);
252
- await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
253
+
254
+ // 读取最近对话片段,帮助确认是否切换到正确的 session
255
+ const recentCtx = getSessionRecentContext ? getSessionRecentContext(sessionId) : null;
256
+ let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}`;
257
+ if (recentCtx) {
258
+ if (recentCtx.lastUser) {
259
+ const snippet = recentCtx.lastUser.replace(/\n/g, ' ').slice(0, 80);
260
+ msg += `\n\n💬 上次你说: _${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}_`;
261
+ }
262
+ if (recentCtx.lastAssistant) {
263
+ const snippet = recentCtx.lastAssistant.replace(/\n/g, ' ').slice(0, 80);
264
+ msg += `\n🤖 上次回复: ${snippet}${recentCtx.lastAssistant.length > 80 ? '…' : ''}`;
265
+ }
266
+ }
267
+ if (bot.sendMarkdown) {
268
+ await bot.sendMarkdown(chatId, msg);
269
+ } else {
270
+ await bot.sendMessage(chatId, msg.replace(/[_*`]/g, ''));
271
+ }
253
272
  return true;
254
273
  }
255
274
 
@@ -700,8 +700,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
700
700
 
701
701
  // Inject daemon hints only on first message of a session
702
702
  const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
703
- 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
704
- 2. File sending: User is on MOBILE. When they ask to see/download a file:
703
+ 1. Language: ALWAYS respond in Simplified Chinese (简体中文). NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.
704
+ 2. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
705
+ 3. File sending: User is on MOBILE. When they ask to see/download a file:
705
706
  - Just FIND the file path (use Glob/ls if needed)
706
707
  - Do NOT read or summarize the file content (wastes tokens)
707
708
  - Add at END of response: [[FILE:/absolute/path/to/file]]
@@ -741,7 +742,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
741
742
  } catch { /* non-critical */ }
742
743
  }
743
744
 
744
- const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint;
745
+ // Always append a compact language guard to prevent accidental Korean/Japanese responses
746
+ const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only.]';
747
+ const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + langGuard;
745
748
 
746
749
  // Git checkpoint before Claude modifies files (for /undo)
747
750
  // Pass the user prompt as label so checkpoint list is human-readable
@@ -604,19 +604,26 @@ function createCommandRouter(deps) {
604
604
  }, 5000);
605
605
  return;
606
606
  }
607
+ // Strict mode: chats with a fixed agent in chat_agent_map must not cross-dispatch
608
+ const _strictChatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
609
+ const _isStrictChat = !!(_strictChatAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
610
+
607
611
  // Nickname-only switch: bypass cooldown + budget (no Claude call)
608
- const quickAgent = routeAgent(text, config);
609
- if (quickAgent && !quickAgent.rest) {
610
- const { key, proj } = quickAgent;
611
- const projCwd = normalizeCwd(proj.cwd);
612
- attachOrCreateSession(chatId, projCwd, proj.name || key);
613
- log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
614
- await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
615
- return;
616
- }
612
+ // Skipped for strict chats (fixed-agent groups)
613
+ if (!_isStrictChat) {
614
+ const quickAgent = routeAgent(text, config);
615
+ if (quickAgent && !quickAgent.rest) {
616
+ const { key, proj } = quickAgent;
617
+ const projCwd = normalizeCwd(proj.cwd);
618
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
619
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
620
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
621
+ return;
622
+ }
617
623
 
618
- if (await tryHandleAgentIntent(bot, chatId, text, config)) {
619
- return;
624
+ if (await tryHandleAgentIntent(bot, chatId, text, config)) {
625
+ return;
626
+ }
620
627
  }
621
628
 
622
629
  const daemonCfg = (config && config.daemon) || {};
@@ -106,6 +106,59 @@ function createSessionStore(deps) {
106
106
 
107
107
  function invalidateSessionCache() { _sessionCache = null; }
108
108
 
109
+ // 监听 ~/.claude/projects 目录,手机端新建 session 后桌面端无需重启即可感知
110
+ let _watcher = null;
111
+ let _invalidateDebounce = null;
112
+
113
+ function _debouncedInvalidate() {
114
+ if (_invalidateDebounce) return;
115
+ _invalidateDebounce = setTimeout(() => {
116
+ _sessionCache = null;
117
+ _invalidateDebounce = null;
118
+ }, 500);
119
+ }
120
+
121
+ function watchSessionFiles() {
122
+ // 先关闭旧 watcher,防止热重载时叠加
123
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
124
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return;
125
+ try {
126
+ _watcher = fs.watch(CLAUDE_PROJECTS_DIR, { recursive: true }, (evt, filename) => {
127
+ if (filename && filename.endsWith('.jsonl')) _debouncedInvalidate();
128
+ });
129
+ _watcher.on('error', (e) => {
130
+ log('WARN', '[session-store] fs.watch error: ' + e.message);
131
+ _watcher = null;
132
+ });
133
+ log('INFO', '[session-store] fs.watch active on ' + CLAUDE_PROJECTS_DIR);
134
+ } catch (e) {
135
+ log('WARN', '[session-store] fs.watch failed, fallback to TTL cache: ' + e.message);
136
+ }
137
+ }
138
+
139
+ function stopWatchingSessionFiles() {
140
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
141
+ }
142
+
143
+ // [M3] 共享辅助:从 reversed JSONL 行数组中提取最后一条外部用户消息(统一规则)
144
+ function extractLastUserFromLines(lines) {
145
+ for (const line of lines) {
146
+ if (!line) continue;
147
+ try {
148
+ const d = JSON.parse(line);
149
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
150
+ const content = d.message.content;
151
+ let raw = typeof content === 'string' ? content
152
+ : Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
153
+ raw = raw.replace(/\[System hints[\s\S]*/i, '')
154
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
155
+ if (raw.length > 2) return raw.slice(0, 80);
156
+ }
157
+ } catch { /* skip */ }
158
+ }
159
+ return '';
160
+ }
161
+
109
162
  function scanAllSessions() {
110
163
  if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
111
164
  try {
@@ -162,49 +215,58 @@ function createSessionStore(deps) {
162
215
  const ENRICH_LIMIT = 20;
163
216
  for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
164
217
  const s = all[i];
165
- if (s.firstPrompt && s.customTitle) continue;
218
+ // [M1] _enriched 标志替代三字段联合判断
219
+ // customTitle 是可选的,无命名 session 合法值为 undefined,不能作为 skip 条件
220
+ if (s._enriched) continue;
166
221
  try {
167
222
  const sessionFile = findSessionFile(s.sessionId);
168
223
  if (!sessionFile) continue;
169
224
  const fd = fs.openSync(sessionFile, 'r');
170
- const headBuf = Buffer.alloc(8192);
171
- const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
172
- const headStr = headBuf.toString('utf8', 0, headBytes);
173
- if (!s.firstPrompt) {
174
- for (const line of headStr.split('\n')) {
175
- if (!line) continue;
176
- try {
177
- const d = JSON.parse(line);
178
- if (d.type === 'user' && d.message && d.userType === 'external') {
179
- const content = d.message.content;
180
- let raw = '';
181
- if (typeof content === 'string') raw = content;
182
- else if (Array.isArray(content)) {
183
- const txt = content.find(c => c.type === 'text');
184
- if (txt) raw = txt.text;
225
+ try {
226
+ if (!s.firstPrompt) {
227
+ const headBuf = Buffer.alloc(8192);
228
+ const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
229
+ const headStr = headBuf.toString('utf8', 0, headBytes);
230
+ for (const line of headStr.split('\n')) {
231
+ if (!line) continue;
232
+ try {
233
+ const d = JSON.parse(line);
234
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
235
+ const content = d.message.content;
236
+ let raw = '';
237
+ if (typeof content === 'string') raw = content;
238
+ else if (Array.isArray(content)) {
239
+ const txt = content.find(c => c.type === 'text');
240
+ if (txt) raw = txt.text;
241
+ }
242
+ raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
243
+ if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
185
244
  }
186
- raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
187
- if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
188
- }
189
- } catch { /* skip line */ }
245
+ } catch { /* skip line */ }
246
+ }
190
247
  }
191
- }
192
- if (!s.customTitle) {
248
+ // 从尾部读取:customTitle + lastUser(256KB 覆盖 tool-heavy session)
193
249
  const stat = fs.fstatSync(fd);
194
- const tailSize = Math.min(4096, stat.size);
250
+ const tailSize = Math.min(262144, stat.size);
195
251
  const tailBuf = Buffer.alloc(tailSize);
196
252
  fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
197
- const tailStr = tailBuf.toString('utf8');
198
- const tailLines = tailStr.split('\n').reverse();
199
- for (const line of tailLines) {
200
- if (!line) continue;
201
- try {
202
- const d = JSON.parse(line);
203
- if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
204
- } catch { /* skip */ }
253
+ const tailLines = tailBuf.toString('utf8').split('\n').reverse();
254
+ if (!s.customTitle) {
255
+ for (const line of tailLines) {
256
+ if (!line) continue;
257
+ try {
258
+ const d = JSON.parse(line);
259
+ if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
260
+ } catch { /* skip */ }
261
+ }
262
+ }
263
+ if (!s.lastUser) {
264
+ s.lastUser = extractLastUserFromLines(tailLines);
205
265
  }
266
+ } finally {
267
+ fs.closeSync(fd);
206
268
  }
207
- fs.closeSync(fd);
269
+ s._enriched = true; // [M1] 标记已完成富化,下次跳过
208
270
  } catch { /* non-fatal */ }
209
271
  }
210
272
 
@@ -265,20 +327,34 @@ function createSessionStore(deps) {
265
327
  .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
266
328
  .replace(/\s+/g, ' ')
267
329
  .trim();
330
+
331
+ // 优先级:name > summary > tags > firstPrompt > sessionId 前缀
268
332
  if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
333
+
334
+ if (s.summary) {
335
+ const t = sanitize(s.summary);
336
+ if (t.length > 2) return t.slice(0, maxLen);
337
+ }
338
+
269
339
  const tagEntry = sessionTags && sessionTags[s.sessionId];
270
340
  if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
341
+
271
342
  if (s.firstPrompt) {
272
343
  const clean = s.firstPrompt
273
344
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
274
- .replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
275
- .replace(/\[System hints[\s\S]*/i, '');
276
- const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
345
+ .replace(/\[System hints[\s\S]*/i, '')
346
+ .replace(/^[\s\S]*?<\/[^>]+>\s*/s, '') // 剥离 XML 头部标签
347
+ .trim();
348
+ // 取第一行非空、有实际内容(非纯符号/空格)的行
349
+ const firstLine = clean.split('\n')
350
+ .map(l => l.trim())
351
+ .find(l => l.length > 4 && /\p{L}/u.test(l)) || '';
277
352
  const sanitized = sanitize(firstLine);
278
353
  if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
279
354
  }
280
- if (s.summary) return sanitize(s.summary).slice(0, maxLen);
281
- return '';
355
+
356
+ // 最终兜底:显示 session ID 前缀而非空白
357
+ return s.sessionId ? s.sessionId.slice(0, 8) : '';
282
358
  }
283
359
 
284
360
  function sessionRichLabel(s, index, sessionTags) {
@@ -291,11 +367,17 @@ function createSessionStore(deps) {
291
367
  const shortId = s.sessionId.slice(0, 8);
292
368
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
293
369
 
294
- let line = `${index}. `;
295
- if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
296
- else line += '(unnamed)';
370
+ // [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
371
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
372
+ // fallback to firstPrompt when lastUser not found in tail
373
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
374
+ let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
297
375
  if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
298
376
  line += `\n 📁${proj} · ${ago}`;
377
+ if (snippetRaw && snippetRaw.length > 2) {
378
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
379
+ line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
380
+ }
299
381
  line += `\n /resume ${shortId}`;
300
382
  return line;
301
383
  }
@@ -312,8 +394,15 @@ function createSessionStore(deps) {
312
394
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
313
395
  const shortId = s.sessionId.slice(0, 6);
314
396
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
315
- let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
397
+ // [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
398
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
399
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
400
+ let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago}`;
316
401
  if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
402
+ if (snippetRaw && snippetRaw.length > 2) {
403
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
404
+ desc += `\n💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
405
+ }
317
406
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
318
407
  elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
319
408
  });
@@ -393,6 +482,49 @@ function createSessionStore(deps) {
393
482
  }
394
483
  }
395
484
 
485
+ /**
486
+ * 读取 session 最近一条用户消息 + 最近一条 AI 回复
487
+ * 用于 /resume 后帮助确认切换到正确 session
488
+ */
489
+ function getSessionRecentContext(sessionId) {
490
+ try {
491
+ const sessionFile = findSessionFile(sessionId);
492
+ if (!sessionFile) return null;
493
+ const stat = fs.statSync(sessionFile);
494
+ const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
495
+ const buf = Buffer.alloc(tailSize);
496
+ const fd = fs.openSync(sessionFile, 'r');
497
+ try {
498
+ fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
499
+ } finally {
500
+ fs.closeSync(fd);
501
+ }
502
+ const lines = buf.toString('utf8').split('\n').reverse();
503
+ // [M3] 复用共享函数,统一截取逻辑
504
+ const lastUser = extractLastUserFromLines(lines);
505
+ let lastAssistant = '';
506
+ for (const line of lines) {
507
+ if (!line.trim()) continue;
508
+ try {
509
+ const d = JSON.parse(line);
510
+ if (!lastAssistant && d.type === 'assistant' && d.message) {
511
+ const content = d.message.content;
512
+ if (Array.isArray(content)) {
513
+ for (const c of content) {
514
+ if (c.type === 'text' && c.text && c.text.trim().length > 2) {
515
+ lastAssistant = c.text.trim().slice(0, 80);
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ if (lastAssistant) break;
522
+ } catch { /* skip bad line */ }
523
+ }
524
+ return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
525
+ } catch { return null; }
526
+ }
527
+
396
528
  function markSessionStarted(chatId) {
397
529
  const state = loadState();
398
530
  if (state.sessions[chatId]) {
@@ -405,6 +537,8 @@ function createSessionStore(deps) {
405
537
  findSessionFile,
406
538
  clearSessionFileCache,
407
539
  truncateSessionToCheckpoint,
540
+ watchSessionFiles,
541
+ stopWatchingSessionFiles,
408
542
  listRecentSessions,
409
543
  loadSessionTags,
410
544
  getSessionFileMtime,
@@ -417,6 +551,7 @@ function createSessionStore(deps) {
417
551
  getSessionName,
418
552
  writeSessionName,
419
553
  markSessionStarted,
554
+ getSessionRecentContext,
420
555
  };
421
556
  }
422
557
 
@@ -0,0 +1,399 @@
1
+ /**
2
+ * daemon-user-acl.js — MetaMe 多用户权限管理模块
3
+ *
4
+ * 角色体系:
5
+ * admin — 全部权限(王总)
6
+ * member — 可配置白名单操作
7
+ * stranger — 仅基础问答,无系统操作
8
+ *
9
+ * 配置文件:~/.metame/users.yaml(热更新,独立于 daemon.yaml)
10
+ * 格式:
11
+ * users:
12
+ * ou_abc123: { role: admin, name: 王总 }
13
+ * ou_def456: { role: member, name: 老马, allowed_actions: [feedback, status, query] }
14
+ * default_role: stranger
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+
23
+ const USERS_FILE = path.join(os.homedir(), '.metame', 'users.yaml');
24
+
25
+ // ─── YAML 轻量解析(无依赖) ─────────────────────────────────────────────────
26
+ // 只解析本文件需要的简单结构,不引入 js-yaml 依赖
27
+ function parseSimpleYaml(content) {
28
+ const result = {};
29
+ const lines = content.split('\n');
30
+ let currentSection = null;
31
+ let currentUserId = null;
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const line = lines[i];
35
+ const trimmed = line.trimEnd();
36
+ if (!trimmed || trimmed.startsWith('#')) continue;
37
+
38
+ const indent = line.length - line.trimStart().length;
39
+ const stripped = trimmed.trim();
40
+
41
+ if (indent === 0) {
42
+ const m = stripped.match(/^(\w[\w_]*):\s*(.*)$/);
43
+ if (m) {
44
+ currentSection = m[1];
45
+ if (m[2]) result[currentSection] = m[2];
46
+ else result[currentSection] = {};
47
+ currentUserId = null;
48
+ }
49
+ } else if (indent === 2 && currentSection === 'users') {
50
+ const m = stripped.match(/^([\w_-]+):\s*\{?(.*)\}?$/);
51
+ if (m) {
52
+ currentUserId = m[1];
53
+ result.users = result.users || {};
54
+ result.users[currentUserId] = parseInlineObj(m[2]);
55
+ }
56
+ } else if (indent === 4 && currentSection === 'users' && currentUserId) {
57
+ const m = stripped.match(/^([\w_]+):\s*(.+)$/);
58
+ if (m) {
59
+ result.users[currentUserId][m[1]] = parseYamlValue(m[2]);
60
+ }
61
+ } else if (indent === 2 && currentSection !== 'users') {
62
+ const m = stripped.match(/^([\w_]+):\s*(.+)$/);
63
+ if (m && typeof result[currentSection] === 'object') {
64
+ result[currentSection][m[1]] = parseYamlValue(m[2]);
65
+ }
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function parseInlineObj(str) {
72
+ const obj = {};
73
+ str = str.replace(/^\{|\}$/g, '').trim();
74
+ if (!str) return obj;
75
+ const parts = str.split(',').map(s => s.trim());
76
+ for (const part of parts) {
77
+ const m = part.match(/^([\w_]+):\s*(.+)$/);
78
+ if (m) obj[m[1]] = parseYamlValue(m[2]);
79
+ }
80
+ return obj;
81
+ }
82
+
83
+ function parseYamlValue(val) {
84
+ val = val.trim();
85
+ if (val === 'true') return true;
86
+ if (val === 'false') return false;
87
+ if (/^\d+$/.test(val)) return parseInt(val, 10);
88
+ // Array like [a, b, c]
89
+ if (val.startsWith('[') && val.endsWith(']')) {
90
+ return val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
91
+ }
92
+ // Quoted string
93
+ return val.replace(/^['"]|['"]$/g, '');
94
+ }
95
+
96
+ // ─── 序列化为 YAML ────────────────────────────────────────────────────────────
97
+ // name 字段转义:去除可能破坏行内 YAML 的特殊字符
98
+ function sanitizeYamlScalar(str) {
99
+ return String(str || '').replace(/[,:{}\[\]\r\n#]/g, '_').slice(0, 40);
100
+ }
101
+
102
+ function serializeUsers(data) {
103
+ const lines = [];
104
+ // 始终写出 default_role,确保 reload 时语义明确
105
+ lines.push(`default_role: ${data.default_role || 'stranger'}`);
106
+ lines.push('users:');
107
+ for (const [uid, info] of Object.entries(data.users || {})) {
108
+ const actions = info.allowed_actions
109
+ ? `, allowed_actions: [${info.allowed_actions.join(', ')}]`
110
+ : '';
111
+ const safeName = info.name ? `, name: ${sanitizeYamlScalar(info.name)}` : '';
112
+ lines.push(` ${uid}: { role: ${info.role}${safeName}${actions} }`);
113
+ }
114
+ return lines.join('\n') + '\n';
115
+ }
116
+
117
+ // 飞书 open_id 格式校验(字母数字下划线,10-64 位)
118
+ function isValidOpenId(uid) {
119
+ return typeof uid === 'string' && /^[a-zA-Z0-9_-]{10,64}$/.test(uid);
120
+ }
121
+
122
+ // ─── 加载用户配置 ─────────────────────────────────────────────────────────────
123
+ let _cachedUsers = null;
124
+ let _cachedMtime = 0;
125
+
126
+ function loadUsers() {
127
+ try {
128
+ const stat = fs.statSync(USERS_FILE);
129
+ if (stat.mtimeMs !== _cachedMtime) {
130
+ const content = fs.readFileSync(USERS_FILE, 'utf8');
131
+ _cachedUsers = parseSimpleYaml(content);
132
+ _cachedMtime = stat.mtimeMs;
133
+ }
134
+ return _cachedUsers || { users: {}, default_role: 'stranger' };
135
+ } catch {
136
+ return { users: {}, default_role: 'stranger' };
137
+ }
138
+ }
139
+
140
+ function saveUsers(data) {
141
+ const dir = path.dirname(USERS_FILE);
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ // [M3] 原子写入:先写临时文件再 rename,防止崩溃时损坏 users.yaml
144
+ const tmp = USERS_FILE + '.tmp';
145
+ fs.writeFileSync(tmp, serializeUsers(data), 'utf8');
146
+ fs.renameSync(tmp, USERS_FILE);
147
+ _cachedMtime = 0; // 强制下次重新加载
148
+ }
149
+
150
+ // ─── 权限动作定义 ─────────────────────────────────────────────────────────────
151
+ // 每个 action 代表一类权限门控点
152
+ const ACTION_GROUPS = {
153
+ // member 可开放的安全操作
154
+ feedback: { desc: '提交反馈' },
155
+ status: { desc: '查询系统状态 (/status, /tasks)' },
156
+ query: { desc: '自然语言问答(只读)' },
157
+ file_read: { desc: '查看文件' },
158
+
159
+ // admin 专属(不可赋予 member)
160
+ system: { desc: '系统操作 (/sh, /mac, /fix)' },
161
+ agent: { desc: 'Agent 调度与管理' },
162
+ config: { desc: '配置修改 (/reload, /model)' },
163
+ admin_acl: { desc: '用户权限管理 (/user *)' },
164
+ };
165
+
166
+ const ROLE_DEFAULT_ACTIONS = {
167
+ admin: Object.keys(ACTION_GROUPS), // 全部权限
168
+ member: ['query'], // 默认只能问答
169
+ stranger: [], // 无系统权限,但允许基础问答由 askClaude readOnly 处理
170
+ };
171
+
172
+ // 不可赋予 member 的 admin 专属 action
173
+ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
174
+
175
+ // ─── 核心 API ─────────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * 根据 senderId 解析用户上下文
179
+ * @param {string|null} senderId 飞书 open_id
180
+ * @param {object} config daemon 配置(兼容旧 operator_ids)
181
+ * @returns {object} userCtx { senderId, role, name, allowedActions, can(action) }
182
+ */
183
+ function resolveUserCtx(senderId, config) {
184
+ const userData = loadUsers();
185
+
186
+ let role, name, allowedActions;
187
+
188
+ if (!senderId) {
189
+ // 无 ID(Telegram 等)— 兼容旧逻辑,视为 admin
190
+ role = 'admin';
191
+ name = 'unknown';
192
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
193
+ } else {
194
+ const userInfo = (userData.users || {})[senderId];
195
+
196
+ if (userInfo) {
197
+ role = userInfo.role || 'member';
198
+ name = userInfo.name || senderId.slice(-6);
199
+ if (role === 'admin') {
200
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
201
+ } else if (role === 'member') {
202
+ // member 的 allowed_actions = 默认 member 权限 ∪ 配置的扩展权限(过滤 admin-only)
203
+ const extra = (userInfo.allowed_actions || []).filter(a => !ADMIN_ONLY_ACTIONS.has(a));
204
+ allowedActions = [...new Set([...ROLE_DEFAULT_ACTIONS.member, ...extra])];
205
+ } else {
206
+ allowedActions = [];
207
+ }
208
+ } else {
209
+ // 兼容旧 operator_ids:若 senderId 在 operator_ids 中,视为 admin
210
+ const operatorIds = (config && config.feishu && config.feishu.operator_ids) || [];
211
+ if (operatorIds.includes(senderId)) {
212
+ role = 'admin';
213
+ name = senderId.slice(-6);
214
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
215
+ } else {
216
+ role = userData.default_role || 'stranger';
217
+ name = senderId.slice(-6);
218
+ allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
219
+ }
220
+ }
221
+ }
222
+
223
+ return {
224
+ senderId,
225
+ role,
226
+ name,
227
+ allowedActions,
228
+ isAdmin: role === 'admin',
229
+ isMember: role === 'member',
230
+ isStranger: role === 'stranger',
231
+ can(action) { return allowedActions.includes(action); },
232
+ readOnly: role !== 'admin',
233
+ };
234
+ }
235
+
236
+ // 所有人(包括 stranger)均可使用的公开命令,集中维护
237
+ const PUBLIC_COMMANDS = ['/myid', '/chatid', '/user whoami'];
238
+
239
+ /**
240
+ * 判断命令文本对应的 action 类型
241
+ */
242
+ function classifyCommandAction(text) {
243
+ if (!text) return 'query';
244
+ const t = text.trim().toLowerCase();
245
+ if (t.startsWith('/sh ') || t.startsWith('/mac ') || t.startsWith('/fix') || t.startsWith('/reset') || t.startsWith('/doctor')) return 'system';
246
+ // /run 执行任务,副作用等级与 system 相同,不应归入 status
247
+ if (t.startsWith('/run ') || t.startsWith('/run\n')) return 'system';
248
+ if (t.startsWith('/agent ') || t.startsWith('/dispatch')) return 'agent';
249
+ if (t.startsWith('/model') || t.startsWith('/reload') || t.startsWith('/budget')) return 'config';
250
+ if (t.startsWith('/user ')) return 'admin_acl';
251
+ if (t.startsWith('/status') || t.startsWith('/tasks')) return 'status';
252
+ if (t.startsWith('/')) return 'system'; // 未知 slash 命令默认需要 system 权限
253
+ return 'query';
254
+ }
255
+
256
+ // ─── /user 管理命令处理 ───────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * 处理 /user 系列命令(仅 admin 可调用)
260
+ * 返回 { handled: boolean, reply: string }
261
+ */
262
+ function handleUserCommand(text, userCtx) {
263
+ if (!text || !text.startsWith('/user')) return { handled: false };
264
+
265
+ const args = text.trim().split(/\s+/);
266
+ // args[0] = '/user', args[1] = subcommand
267
+
268
+ const sub = args[1];
269
+
270
+ if (!sub || sub === 'help') {
271
+ return {
272
+ handled: true,
273
+ reply: `**用户权限管理**\n\n` +
274
+ `/user list — 列出所有用户\n` +
275
+ `/user add <open_id> <role> [name] — 添加用户 (role: admin/member)\n` +
276
+ `/user role <open_id> <role> — 修改角色\n` +
277
+ `/user grant <open_id> <action> — 赋予 member 额外权限\n` +
278
+ `/user revoke <open_id> <action> — 撤销 member 权限\n` +
279
+ `/user remove <open_id> — 移除用户\n` +
280
+ `/user actions — 列出可用 action\n` +
281
+ `/user whoami — 查看当前身份`,
282
+ };
283
+ }
284
+
285
+ if (sub === 'whoami') {
286
+ return {
287
+ handled: true,
288
+ reply: `**你的身份**\n\nID: \`${userCtx.senderId || 'N/A'}\`\n角色: ${userCtx.role}\n名称: ${userCtx.name}\n权限: ${userCtx.allowedActions.join(', ') || '无'}`,
289
+ };
290
+ }
291
+
292
+ if (sub === 'actions') {
293
+ const lines = Object.entries(ACTION_GROUPS).map(([k, v]) => `- \`${k}\` — ${v.desc}`);
294
+ return { handled: true, reply: `**可用 Actions**\n\n${lines.join('\n')}` };
295
+ }
296
+
297
+ if (sub === 'list') {
298
+ const data = loadUsers();
299
+ const users = Object.entries(data.users || {});
300
+ if (!users.length) return { handled: true, reply: '暂无用户配置(仅依赖 operator_ids)' };
301
+ const lines = users.map(([uid, info]) => {
302
+ const actions = info.allowed_actions ? ` | ${info.allowed_actions.join(',')}` : '';
303
+ return `- \`${uid}\` [${info.role}] ${info.name || ''}${actions}`;
304
+ });
305
+ return { handled: true, reply: `**用户列表** (default: ${data.default_role || 'stranger'})\n\n${lines.join('\n')}` };
306
+ }
307
+
308
+ // [S1] 破坏性子命令内部守卫(防止外部绕过调用方校验)
309
+ const ADMIN_REQUIRED_SUBS = new Set(['add', 'role', 'grant', 'revoke', 'remove']);
310
+ if (ADMIN_REQUIRED_SUBS.has(sub) && !userCtx.isAdmin) {
311
+ return { handled: true, reply: '⚠️ 此操作需要 admin 权限,请联系管理员。' };
312
+ }
313
+
314
+ if (sub === 'add') {
315
+ // /user add <open_id> <role> [name...]
316
+ const [, , , uid, role, ...nameParts] = args;
317
+ if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
318
+ // [S2] open_id 格式校验
319
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
320
+ if (!['admin', 'member', 'stranger'].includes(role)) {
321
+ return { handled: true, reply: '角色必须是 admin / member / stranger' };
322
+ }
323
+ const data = loadUsers();
324
+ data.users = data.users || {};
325
+ data.users[uid] = { role, name: nameParts.join(' ') || uid.slice(-6) };
326
+ saveUsers(data);
327
+ return { handled: true, reply: `✅ 已添加用户 \`${uid}\` → ${role}` };
328
+ }
329
+
330
+ if (sub === 'role') {
331
+ const [, , , uid, role] = args;
332
+ if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
333
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
334
+ if (!['admin', 'member', 'stranger'].includes(role)) {
335
+ return { handled: true, reply: '角色必须是 admin / member / stranger' };
336
+ }
337
+ const data = loadUsers();
338
+ data.users = data.users || {};
339
+ if (!data.users[uid]) data.users[uid] = {};
340
+ data.users[uid].role = role;
341
+ saveUsers(data);
342
+ return { handled: true, reply: `✅ 用户 \`${uid}\` 角色已更新为 ${role}` };
343
+ }
344
+
345
+ if (sub === 'grant') {
346
+ const [, , , uid, action] = args;
347
+ if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
348
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
349
+ if (ADMIN_ONLY_ACTIONS.has(action)) {
350
+ return { handled: true, reply: `❌ \`${action}\` 是 admin 专属权限,不可赋予 member` };
351
+ }
352
+ if (!ACTION_GROUPS[action]) {
353
+ return { handled: true, reply: `❌ 未知 action: ${action},用 /user actions 查看可用列表` };
354
+ }
355
+ const data = loadUsers();
356
+ data.users = data.users || {};
357
+ if (!data.users[uid]) data.users[uid] = { role: 'member' };
358
+ const existing = data.users[uid].allowed_actions || [];
359
+ if (!existing.includes(action)) {
360
+ data.users[uid].allowed_actions = [...existing, action];
361
+ saveUsers(data);
362
+ }
363
+ return { handled: true, reply: `✅ 已授权 \`${uid}\` → ${action}` };
364
+ }
365
+
366
+ if (sub === 'revoke') {
367
+ const [, , , uid, action] = args;
368
+ if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
369
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
370
+ const data = loadUsers();
371
+ const userInfo = (data.users || {})[uid];
372
+ if (!userInfo) return { handled: true, reply: `❌ 未找到用户 \`${uid}\`` };
373
+ userInfo.allowed_actions = (userInfo.allowed_actions || []).filter(a => a !== action);
374
+ saveUsers(data);
375
+ return { handled: true, reply: `✅ 已撤销 \`${uid}\` 的 ${action} 权限` };
376
+ }
377
+
378
+ if (sub === 'remove') {
379
+ const [, , , uid] = args;
380
+ if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
381
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
382
+ const data = loadUsers();
383
+ delete (data.users || {})[uid];
384
+ saveUsers(data);
385
+ return { handled: true, reply: `✅ 已移除用户 \`${uid}\`` };
386
+ }
387
+
388
+ return { handled: true, reply: `未知子命令: ${sub},用 /user help 查看帮助` };
389
+ }
390
+
391
+ module.exports = {
392
+ resolveUserCtx,
393
+ classifyCommandAction,
394
+ handleUserCommand,
395
+ loadUsers,
396
+ saveUsers,
397
+ ACTION_GROUPS,
398
+ PUBLIC_COMMANDS,
399
+ };
package/scripts/daemon.js CHANGED
@@ -1218,6 +1218,7 @@ const {
1218
1218
  getSessionFileMtime,
1219
1219
  sessionLabel,
1220
1220
  sessionRichLabel,
1221
+ getSessionRecentContext,
1221
1222
  buildSessionCardElements,
1222
1223
  listProjectDirs,
1223
1224
  getSession,
@@ -1225,6 +1226,7 @@ const {
1225
1226
  getSessionName,
1226
1227
  writeSessionName,
1227
1228
  markSessionStarted,
1229
+ watchSessionFiles,
1228
1230
  } = createSessionStore({
1229
1231
  fs,
1230
1232
  path,
@@ -1236,6 +1238,8 @@ const {
1236
1238
  cpExtractTimestamp,
1237
1239
  });
1238
1240
 
1241
+ watchSessionFiles(); // 热加载:手机端新建 session 后桌面无需重启
1242
+
1239
1243
  // Active Claude processes per chat (for /stop)
1240
1244
  const activeProcesses = new Map(); // chatId -> { child, aborted }
1241
1245
 
@@ -1485,6 +1489,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1485
1489
  sessionLabel,
1486
1490
  loadSessionTags,
1487
1491
  sessionRichLabel,
1492
+ getSessionRecentContext,
1488
1493
  pendingBinds,
1489
1494
  pendingAgentFlows,
1490
1495
  doBindAgent,