@seamnet/client 0.14.5 → 0.16.0

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/bin/cli.js CHANGED
@@ -1,9 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { parseArgs } from 'node:util';
4
- import { init } from '../lib/init.js';
5
- import { status } from '../lib/status.js';
6
- import { stop } from '../lib/stop.js';
4
+ import { join } from 'node:path';
7
5
 
8
6
  const command = process.argv[2];
9
7
 
@@ -12,13 +10,19 @@ if (!command || command === '--help' || command === '-h') {
12
10
  seam-client — join the Seam network
13
11
 
14
12
  Commands:
15
- init --invite-code <code> --name <name> Register and join Seam
16
- --api <url> API base (default: $SEAM_API_BASE || https://seam.chat)
17
- guardian start|stop|run Manage guardian background process
18
- autostart Start guardian if credentials exist and not running (CC SessionStart hook)
19
- status Check Seam status
20
- stop Stop guardian
21
- mcp-serve Start MCP server (used by Claude Code)
13
+ init --invite-code <code> --name <name> Register and join Seam
14
+ --api <url> API base (default: $SEAM_API_BASE || https://seam.chat)
15
+ guardian start|stop|run Manage guardian background process
16
+ autostart Start guardian if credentials exist and not running (CC SessionStart hook)
17
+ status Check Seam status
18
+ stop Stop guardian
19
+ upgrade Upgrade this AI's seam-client (per-AI mode)
20
+ upgrade-all Host-tool: bump global package + restart all registered guardians
21
+ mcp-serve Start MCP server (used by Claude Code)
22
+
23
+ Environment:
24
+ SEAM_HOME Absolute path to a .seam data directory. Resolution priority:
25
+ 1) $SEAM_HOME, 2) cwd-upwalk for .seam/, 3) $HOME/.seam fallback.
22
26
 
23
27
  Example:
24
28
  npx seam-client init --invite-code ABC123 --name my-ai
@@ -46,8 +50,11 @@ try {
46
50
  console.error('Error: --name is required');
47
51
  process.exit(1);
48
52
  }
49
- // 优先级:--api 参数 > SEAM_API_BASE 环境变量 > 默认 https://seam.chat
53
+ if (!process.env.SEAM_HOME) {
54
+ process.env.SEAM_HOME = join(process.cwd(), '.seam');
55
+ }
50
56
  const apiBase = values.api || process.env.SEAM_API_BASE || 'https://seam.chat';
57
+ const { init } = await import('../lib/init.js');
51
58
  await init({
52
59
  inviteCode: values['invite-code'],
53
60
  name: values.name,
@@ -55,14 +62,21 @@ try {
55
62
  });
56
63
  break;
57
64
  }
58
- case 'status':
65
+ case 'status': {
66
+ const { status } = await import('../lib/status.js');
59
67
  await status();
60
68
  break;
69
+ }
61
70
  case 'upgrade': {
62
71
  const { upgrade } = await import('../lib/upgrade.js');
63
72
  await upgrade();
64
73
  break;
65
74
  }
75
+ case 'upgrade-all': {
76
+ const { upgradeAll } = await import('../lib/upgrade-all.js');
77
+ await upgradeAll();
78
+ break;
79
+ }
66
80
  case 'stop': {
67
81
  const { guardianStop } = await import('../lib/guardian.js');
68
82
  await guardianStop();
package/bin/seam.js CHANGED
@@ -47,6 +47,7 @@ function printHelp() {
47
47
  ' seam msg group --group <id> (--text <text> | --text-file <path> | --image <path>)',
48
48
  ' seam contacts list',
49
49
  ' seam contacts get --user <userId>',
50
+ ' seam tell --to <id|alias> [--from <name>] --text <text>',
50
51
  ' seam wechat bind',
51
52
  ' seam wechat send (--text <text> | --text-file <path> | --image <path> | --file <path>)',
52
53
  ' seam wechat status',
@@ -169,6 +170,30 @@ async function cmdMsgSend(restArgs) {
169
170
  }
170
171
  }
171
172
 
173
+ async function cmdTell(restArgs) {
174
+ const { values } = parseArgs({
175
+ args: restArgs,
176
+ options: {
177
+ to: { type: 'string' },
178
+ from: { type: 'string' },
179
+ text: { type: 'string' },
180
+ 'text-file': { type: 'string' },
181
+ },
182
+ strict: false,
183
+ });
184
+ if (!values.to) output(false, '--to required');
185
+ const text = readText({ text: values.text, textFile: values['text-file'] });
186
+ if (text == null) output(false, '--text or --text-file required');
187
+ const { tell } = await import('../lib/tell.js');
188
+ const res = await tell({
189
+ to: values.to,
190
+ from: values.from,
191
+ text,
192
+ guardianRequest,
193
+ });
194
+ output(res.ok !== false, res);
195
+ }
196
+
172
197
  async function cmdMsgGroup(restArgs) {
173
198
  const { values } = parseArgs({
174
199
  args: restArgs,
@@ -377,43 +402,11 @@ async function cmdInvite(subAction, restArgs) {
377
402
 
378
403
  // === cc (Claude Code 管理) ===
379
404
 
380
- import { execSync, spawn } from 'node:child_process';
381
-
382
- function ccGetSessions() {
383
- let lines;
384
- try {
385
- lines = execSync('tmux list-sessions -F "#{session_name}"', {
386
- encoding: 'utf8', timeout: 5000,
387
- }).trim().split('\n').filter(Boolean);
388
- } catch {
389
- return [];
390
- }
391
- return lines.map(name => {
392
- let ccRunning = false;
393
- try {
394
- const panePid = execSync(`tmux display-message -t "${name}" -p '#{pane_pid}'`, {
395
- encoding: 'utf8', timeout: 3000,
396
- }).trim();
397
- // pane 进程本身可能就是 claude(tmux 直接启动时),也可能是 bash 的子进程
398
- const self = execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, {
399
- encoding: 'utf8', timeout: 3000,
400
- }).trim();
401
- let child = '';
402
- try {
403
- child = execSync(`ps -o comm= --ppid ${panePid} 2>/dev/null`, {
404
- encoding: 'utf8', timeout: 3000,
405
- }).trim().split('\n').pop() || '';
406
- } catch {}
407
- ccRunning = ['claude', 'claude-code'].includes(self) ||
408
- ['claude', 'claude-code'].includes(child);
409
- } catch {}
410
- return { session: name, cc: ccRunning };
411
- });
412
- }
405
+ const tmuxUtils = require('../lib/tmux-utils.cjs');
413
406
 
414
407
  async function cmdCc(subAction, restArgs) {
415
408
  if (!subAction || subAction === 'list') {
416
- const sessions = ccGetSessions();
409
+ const sessions = tmuxUtils.listSessions();
417
410
  if (sessions.length === 0) output(true, { sessions: [], message: 'no tmux sessions' });
418
411
  output(true, { sessions });
419
412
  return;
@@ -430,10 +423,7 @@ async function cmdCc(subAction, restArgs) {
430
423
  });
431
424
  if (!values.session) output(false, '--session required');
432
425
  try {
433
- const text = execSync(
434
- `tmux capture-pane -t "${values.session}" -p | tail -${parseInt(values.lines) || 20}`,
435
- { encoding: 'utf8', timeout: 5000 }
436
- );
426
+ const text = tmuxUtils.readOutput(values.session, parseInt(values.lines) || 20);
437
427
  output(true, { session: values.session, lines: text.trimEnd().split('\n') });
438
428
  } catch (e) {
439
429
  output(false, `read failed: ${e.message}`);
@@ -452,24 +442,11 @@ async function cmdCc(subAction, restArgs) {
452
442
  });
453
443
  if (!values.session) output(false, '--session required');
454
444
  if (!values.text) output(false, '--text required');
455
- const sessions = ccGetSessions();
456
- const target = sessions.find(s => s.session === values.session);
457
- if (!target) output(false, `session "${values.session}" not found`);
458
- if (!target.cc) output(false, `session "${values.session}": CC not running`);
459
445
  try {
460
- // 分两步:先发文本(literal),再单独发 Enter
461
- // CC 用 raw terminal mode,合在一起发会丢回车
462
- execSync(
463
- `tmux send-keys -t "${values.session}" -l ${JSON.stringify(values.text)}`,
464
- { timeout: 5000 }
465
- );
466
- execSync(
467
- `tmux send-keys -t "${values.session}" Enter`,
468
- { timeout: 5000 }
469
- );
446
+ tmuxUtils.sendToCC(values.session, values.text);
470
447
  output(true, { session: values.session, sent: values.text });
471
448
  } catch (e) {
472
- output(false, `send failed: ${e.message}`);
449
+ output(false, e.message);
473
450
  }
474
451
  return;
475
452
  }
@@ -484,21 +461,12 @@ async function cmdCc(subAction, restArgs) {
484
461
  strict: false,
485
462
  });
486
463
  if (!values.dir) output(false, '--dir required');
487
- if (!existsSync(values.dir)) output(false, `dir not found: ${values.dir}`);
488
- const sessionName = values.session || join(values.dir).split('/').pop();
489
- // 检查 session 是否已存在
490
- const existing = ccGetSessions();
491
- if (existing.find(s => s.session === sessionName)) {
492
- output(false, `session "${sessionName}" already exists`);
493
- }
464
+ const sessionName = values.session || values.dir.split('/').pop();
494
465
  try {
495
- execSync(
496
- `tmux new-session -d -s "${sessionName}" -c "${values.dir}" 'claude --dangerously-skip-permissions'`,
497
- { timeout: 10000 }
498
- );
466
+ tmuxUtils.startCC(values.dir, sessionName);
499
467
  output(true, { session: sessionName, dir: values.dir, started: true });
500
468
  } catch (e) {
501
- output(false, `start failed: ${e.message}`);
469
+ output(false, e.message);
502
470
  }
503
471
  return;
504
472
  }
@@ -528,6 +496,7 @@ function parseDuration(str) {
528
496
  if (action === 'group') return await cmdMsgGroup(rest);
529
497
  output(false, `Unknown msg action: ${action}`);
530
498
  }
499
+ if (domain === 'tell') return await cmdTell(argv.slice(1));
531
500
  if (domain === 'contacts') return await cmdContacts(action, rest);
532
501
  if (domain === 'guardian') return await cmdGuardian(action);
533
502
  if (domain === 'wechat') return await cmdWechat(action, rest);
package/lib/guardian.js CHANGED
@@ -15,6 +15,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, openSyn
15
15
  import { join, isAbsolute } from 'node:path';
16
16
  import { execSync, spawn } from 'node:child_process';
17
17
  import { SEAM_DIR, CREDENTIALS_PATH, SOCKET_PATH, LOGS_DIR, PID_PATH } from './paths.js';
18
+ import { writeEntry as registryWrite, removeEntry as registryRemove } from './registry.js';
18
19
 
19
20
  /**
20
21
  * 解析 $TMUX 里的 socket path。返回可用于 `tmux -S <path>` 的绝对路径或 null。
@@ -136,16 +137,36 @@ export async function guardianRun() {
136
137
  console.error(`Guardian already running (pid: ${existingPid}). Exiting.`);
137
138
  process.exit(0);
138
139
  }
139
- // 写 PID(覆盖 guardianStart 写的 child.pid),退出时清理
140
- writeFileSync(PID_PATH, String(process.pid));
141
- const cleanPid = () => { try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {} };
142
- process.on('exit', cleanPid);
143
- process.on('SIGTERM', () => { cleanPid(); process.exit(0); });
144
- process.on('SIGINT', () => { cleanPid(); process.exit(0); });
145
-
146
140
  const credentials = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
147
141
  const ccSession = process.env.SEAM_CC_SESSION || '';
148
142
  const ccSocket = process.env.SEAM_CC_SOCKET || resolveTmuxSocketPath() || '';
143
+
144
+ // 写 PID(覆盖 guardianStart 写的 child.pid),退出时清理
145
+ writeFileSync(PID_PATH, String(process.pid));
146
+
147
+ // 写 cross-AI registry entry(~/.shared/seam-registry/<userId>.json)
148
+ try {
149
+ registryWrite({
150
+ userId: credentials.userId,
151
+ aliases: [credentials.name].filter(Boolean),
152
+ seam_home: SEAM_DIR,
153
+ tmux_session: ccSession,
154
+ tmux_socket: ccSocket,
155
+ socket: SOCKET_PATH,
156
+ pid: process.pid,
157
+ started_at: new Date().toISOString(),
158
+ });
159
+ } catch (e) {
160
+ console.error(`[guardian] registry write failed (non-fatal): ${e.message}`);
161
+ }
162
+
163
+ const cleanup = () => {
164
+ try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
165
+ try { registryRemove(credentials.userId); } catch {}
166
+ };
167
+ process.on('exit', cleanup);
168
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
169
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
149
170
  const jsonlPath = join(LOGS_DIR, 'guardian.jsonl');
150
171
 
151
172
  if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
@@ -219,35 +240,28 @@ export async function guardianRun() {
219
240
  ? '🔄 [Seam] 升级完成。Guardian 将在 10 秒后重启 CC 以加载新的 MCP 工具。你会在新对话里看到单个 seam 工具取代原来的多个工具。'
220
241
  : '🔄 [Seam] 入网完成。Guardian 将在 10 秒后重启 CC 以加载 MCP 工具。重启后你会在新的对话里读到 IDENTITY.md。';
221
242
  hub.inject(notice);
222
- setTimeout(() => {
243
+ setTimeout(async () => {
223
244
  try {
224
- execSync(`${tmux} send-keys -t ${safeSession} '/exit' Enter`, {
225
- stdio: 'ignore',
226
- timeout: 5000,
227
- });
228
- setTimeout(() => {
229
- try {
230
- execSync(
231
- `${tmux} send-keys -t ${safeSession} 'claude --dangerously-skip-permissions --continue' Enter`,
232
- { stdio: 'ignore', timeout: 5000 }
233
- );
234
- guardianState.set('cc_restarted', new Date().toISOString());
235
- if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
236
- hub.logger('guardian').info('cc_restart_injected');
237
- } catch (e) {
238
- hub.logger('guardian').error('cc_restart_failed', e);
239
- }
240
- }, 3000);
245
+ const { restartCC } = require('./tmux-utils.cjs');
246
+ const ok = await restartCC(safeSession, { socketPath: ccSocket, continueSession: true });
247
+ if (ok) {
248
+ guardianState.set('cc_restarted', new Date().toISOString());
249
+ if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
250
+ hub.logger('guardian').info('cc_restart_injected');
251
+ } else {
252
+ hub.logger('guardian').error('cc_restart_failed', new Error('restartCC returned false'));
253
+ }
241
254
  } catch (e) {
242
- hub.logger('guardian').error('cc_exit_failed', e);
255
+ hub.logger('guardian').error('cc_restart_failed', e);
243
256
  }
244
- }, 10000); // 3s → 10s 给 AI 读到通知的时间
257
+ }, 10000);
245
258
  }
246
259
 
247
- // Keep alive + graceful shutdown (pid 文件清理)
260
+ // Keep alive + graceful shutdown (pid 文件清理 + registry 清理)
248
261
  const shutdown = async (signal) => {
249
262
  hub.logger('guardian').info('shutdown_signal', { signal });
250
263
  try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
264
+ try { registryRemove(credentials.userId); } catch {}
251
265
  await stop();
252
266
  process.exit(0);
253
267
  };
package/lib/init.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'n
2
2
  import { execSync } from 'node:child_process';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { homedir } from 'node:os';
5
6
  import { register } from './api.js';
6
7
  import {
7
8
  SEAM_DIR, CREDENTIALS_PATH, VERSION_PATH,
@@ -85,9 +86,12 @@ export async function init({ inviteCode, name, apiBase }) {
85
86
  // Step 9: Write .mcp.json in current directory
86
87
  writeMcpConfig(result);
87
88
 
88
- // Step 10: Write .claude/settings.json (pre-authorize MCP)
89
+ // Step 10: Write .claude/settings.json (pre-authorize MCP + inject SEAM_HOME)
89
90
  writeSettings();
90
91
 
92
+ // Step 10b: Register this SEAM_HOME so `upgrade-all` can find it
93
+ registerSeamHome();
94
+
91
95
  // Step 11: Add @IDENTITY.md to CLAUDE.md
92
96
  patchClaudeMd();
93
97
 
@@ -196,6 +200,10 @@ function writeSettings() {
196
200
 
197
201
  settings.enableAllProjectMcpServers = true;
198
202
 
203
+ // host-tool: CC 子进程注入 SEAM_HOME,paths.js 第一级 fallback 命中
204
+ if (!settings.env) settings.env = {};
205
+ settings.env.SEAM_HOME = SEAM_DIR;
206
+
199
207
  // SessionStart hook: CC 启动时自动拉起 guardian(如果还没跑)
200
208
  if (!settings.hooks) settings.hooks = {};
201
209
  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
@@ -218,7 +226,22 @@ function writeSettings() {
218
226
  }
219
227
 
220
228
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
221
- console.log(' MCP pre-authorized + SessionStart guardian autostart wired.');
229
+ console.log(` MCP pre-authorized + env.SEAM_HOME=${SEAM_DIR} + SessionStart hook wired.`);
230
+ }
231
+
232
+ function registerSeamHome() {
233
+ const homesPath = join(homedir(), '.seam-homes');
234
+ let lines = [];
235
+ if (existsSync(homesPath)) {
236
+ lines = readFileSync(homesPath, 'utf8').split('\n').filter(Boolean);
237
+ }
238
+ if (!lines.includes(SEAM_DIR)) {
239
+ lines.push(SEAM_DIR);
240
+ writeFileSync(homesPath, lines.join('\n') + '\n');
241
+ console.log(` Registered to ${homesPath}`);
242
+ } else {
243
+ console.log(` Already registered in ${homesPath}`);
244
+ }
222
245
  }
223
246
 
224
247
  export function patchClaudeMd() {
package/lib/paths.js CHANGED
@@ -1,6 +1,23 @@
1
- import { join } from 'node:path';
1
+ import { join, resolve, dirname } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
2
4
 
3
- export const SEAM_DIR = join(process.cwd(), '.seam');
5
+ export function resolveSeamHome() {
6
+ if (process.env.SEAM_HOME) return resolve(process.env.SEAM_HOME);
7
+
8
+ let dir = process.cwd();
9
+ while (true) {
10
+ const candidate = join(dir, '.seam');
11
+ if (existsSync(candidate)) return candidate;
12
+ const parent = dirname(dir);
13
+ if (parent === dir) break;
14
+ dir = parent;
15
+ }
16
+
17
+ return join(homedir(), '.seam');
18
+ }
19
+
20
+ export const SEAM_DIR = resolveSeamHome();
4
21
  export const CREDENTIALS_PATH = join(SEAM_DIR, 'credentials.json');
5
22
  export const CONFIG_PATH = join(SEAM_DIR, 'config.json');
6
23
  export const VERSION_PATH = join(SEAM_DIR, 'version.json');
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Cross-AI registry:~/.shared/seam-registry/<userId>.json
3
+ *
4
+ * Guardian 启动时写 entry,shutdown 时删 entry。`seam tell` 读 entries 查目标
5
+ * AI。死信检测在读时做:pid 已死的 entry 直接删除。
6
+ *
7
+ * 并发写:每条 entry 单独文件 + atomic rename(写 .tmp 再 rename)。
8
+ * 同 userId 双 guardian 不应出现(guardian 单例锁保证),出现则后写覆盖先写。
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ const REGISTRY_DIR = join(homedir(), '.shared', 'seam-registry');
16
+
17
+ function ensureDir() {
18
+ if (!existsSync(REGISTRY_DIR)) {
19
+ mkdirSync(REGISTRY_DIR, { recursive: true });
20
+ }
21
+ }
22
+
23
+ function isAlive(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function entryPath(userId) {
34
+ return join(REGISTRY_DIR, `${userId}.json`);
35
+ }
36
+
37
+ export function writeEntry(entry) {
38
+ if (!entry?.userId) throw new Error('writeEntry: userId required');
39
+ ensureDir();
40
+ const target = entryPath(entry.userId);
41
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
42
+ writeFileSync(tmp, JSON.stringify(entry, null, 2));
43
+ renameSync(tmp, target);
44
+ return target;
45
+ }
46
+
47
+ export function removeEntry(userId) {
48
+ if (!userId) return;
49
+ const target = entryPath(userId);
50
+ try {
51
+ if (existsSync(target)) unlinkSync(target);
52
+ } catch {}
53
+ }
54
+
55
+ /**
56
+ * 列出所有活 entry。死信(pid 已死)会被顺手删除。
57
+ * 顺便清掉残留 .tmp.* 文件(上次崩溃留下的)。
58
+ */
59
+ export function readAll() {
60
+ if (!existsSync(REGISTRY_DIR)) return [];
61
+ let files;
62
+ try {
63
+ files = readdirSync(REGISTRY_DIR);
64
+ } catch {
65
+ return [];
66
+ }
67
+ const alive = [];
68
+ for (const f of files) {
69
+ const full = join(REGISTRY_DIR, f);
70
+ if (f.includes('.tmp.')) {
71
+ try { unlinkSync(full); } catch {}
72
+ continue;
73
+ }
74
+ if (!f.endsWith('.json')) continue;
75
+ let entry;
76
+ try {
77
+ entry = JSON.parse(readFileSync(full, 'utf8'));
78
+ } catch {
79
+ try { unlinkSync(full); } catch {}
80
+ continue;
81
+ }
82
+ if (!isAlive(entry.pid)) {
83
+ try { unlinkSync(full); } catch {}
84
+ continue;
85
+ }
86
+ alive.push(entry);
87
+ }
88
+ return alive;
89
+ }
90
+
91
+ export function findByAlias(needle) {
92
+ if (!needle) return null;
93
+ const entries = readAll();
94
+ for (const e of entries) {
95
+ if (e.userId === needle) return e;
96
+ if (Array.isArray(e.aliases) && e.aliases.includes(needle)) return e;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export { REGISTRY_DIR };
package/lib/tell.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * seam tell:跨 AI 单条投递。
3
+ *
4
+ * 优先走 cross-AI registry —— 目标 AI 在本机 → 直接调 tmux sendToCC 注入;
5
+ * 不在或 socket 死 → fallback 走 IM 发到对方 userId。
6
+ *
7
+ * 入站 inject 加 from header:`💬 [本机 ← <from>] <text>`
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { findByAlias } from './registry.js';
13
+ import { CREDENTIALS_PATH } from './paths.js';
14
+
15
+ const require = createRequire(import.meta.url);
16
+
17
+ function resolveSelfName() {
18
+ if (!existsSync(CREDENTIALS_PATH)) return null;
19
+ try {
20
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))?.name || null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function formatLocalInject(from, text) {
27
+ const tag = from ? `[本机 ← ${from}]` : '[本机]';
28
+ return `💬 ${tag} ${text}`;
29
+ }
30
+
31
+ /**
32
+ * @returns {Promise<{ok:true, channel:'local'|'im', target:string} | {ok:false, error:string}>}
33
+ */
34
+ export async function tell({ to, from, text, guardianRequest }) {
35
+ if (!to) return { ok: false, error: '--to required' };
36
+ if (!text) return { ok: false, error: '--text required' };
37
+
38
+ const fromName = from || resolveSelfName() || '?';
39
+
40
+ const entry = findByAlias(to);
41
+ if (entry?.tmux_session) {
42
+ try {
43
+ const { sendToCC } = require('./tmux-utils.cjs');
44
+ const injectText = formatLocalInject(fromName, text);
45
+ sendToCC(entry.tmux_session, injectText, { socketPath: entry.tmux_socket || undefined });
46
+ return { ok: true, channel: 'local', target: entry.userId };
47
+ } catch (e) {
48
+ // 本机命中但 CC 不在线 / tmux session 死了 → 落 IM
49
+ if (!guardianRequest) {
50
+ return { ok: false, error: `local inject failed: ${e.message}` };
51
+ }
52
+ }
53
+ }
54
+
55
+ if (!guardianRequest) {
56
+ return { ok: false, error: `target "${to}" not in registry and no IM fallback wired` };
57
+ }
58
+
59
+ const targetUserId = entry?.userId || to;
60
+ try {
61
+ const res = await guardianRequest({ action: 'send_im', to: targetUserId, text });
62
+ if (res?.ok === false) return { ok: false, error: res.error || 'IM fallback failed' };
63
+ return { ok: true, channel: 'im', target: targetUserId };
64
+ } catch (e) {
65
+ return { ok: false, error: `IM fallback error: ${e.message}` };
66
+ }
67
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * tmux-utils — 可靠的 tmux 交互。
3
+ *
4
+ * CC 用 raw terminal mode,tmux send-keys 需要特殊处理:
5
+ * 1. 文本用 -l (literal) 发,不解释特殊键
6
+ * 2. Enter 单独发
7
+ * 3. 发完验证是否被接收
8
+ */
9
+
10
+ const { execSync } = require('node:child_process');
11
+ const { existsSync, readFileSync } = require('node:fs');
12
+ const { join } = require('node:path');
13
+
14
+ const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] };
15
+
16
+ function tmuxCmd(socketPath) {
17
+ return socketPath ? `tmux -S "${socketPath}"` : 'tmux';
18
+ }
19
+
20
+ function listSessions(socketPath) {
21
+ const tmux = tmuxCmd(socketPath);
22
+ let lines;
23
+ try {
24
+ lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
25
+ .trim().split('\n').filter(Boolean);
26
+ } catch {
27
+ return [];
28
+ }
29
+ return lines.map(name => {
30
+ const cc = isCCRunning(name, socketPath);
31
+ return { session: name, cc };
32
+ });
33
+ }
34
+
35
+ function isCCRunning(session, socketPath) {
36
+ const tmux = tmuxCmd(socketPath);
37
+ try {
38
+ const panePid = execSync(
39
+ `${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS
40
+ ).trim();
41
+ const self = execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, EXEC_OPTS).trim();
42
+ if (['claude', 'claude-code'].includes(self)) return true;
43
+ try {
44
+ const child = execSync(`ps -o comm= --ppid ${panePid} 2>/dev/null`, EXEC_OPTS)
45
+ .trim().split('\n').pop() || '';
46
+ if (['claude', 'claude-code'].includes(child)) return true;
47
+ } catch {}
48
+ } catch {}
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * 给 tmux session 里的 CC 发用户消息。
54
+ * 用 -l (literal) 发文本 + 单独 Enter,保证 raw terminal mode 下不丢回车。
55
+ */
56
+ function sendToCC(session, text, { socketPath, skipCheck = false } = {}) {
57
+ const tmux = tmuxCmd(socketPath);
58
+ if (!skipCheck && !isCCRunning(session, socketPath)) {
59
+ throw new Error(`session "${session}": CC not running`);
60
+ }
61
+ // 用 -l 发纯文本(不解释特殊键)
62
+ execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
63
+ // 单独发 Enter 提交
64
+ execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
65
+ }
66
+
67
+ /**
68
+ * 读 tmux session 的最后 N 行输出。
69
+ */
70
+ function readOutput(session, lines = 20, socketPath) {
71
+ const tmux = tmuxCmd(socketPath);
72
+ return execSync(
73
+ `${tmux} capture-pane -t "${session}" -p | tail -${parseInt(lines) || 20}`,
74
+ EXEC_OPTS
75
+ );
76
+ }
77
+
78
+ /**
79
+ * 在新 tmux session 里启动 CC。
80
+ */
81
+ function startCC(dir, session, socketPath) {
82
+ const tmux = tmuxCmd(socketPath);
83
+ if (!existsSync(dir)) throw new Error(`dir not found: ${dir}`);
84
+ try {
85
+ execSync(`${tmux} has-session -t "${session}" 2>/dev/null`, EXEC_OPTS);
86
+ throw new Error(`session "${session}" already exists`);
87
+ } catch (e) {
88
+ if (e.message.includes('already exists')) throw e;
89
+ }
90
+ execSync(
91
+ `${tmux} new-session -d -s "${session}" -c "${dir}" 'claude --dangerously-skip-permissions'`,
92
+ { ...EXEC_OPTS, timeout: 10000 }
93
+ );
94
+ }
95
+
96
+ /**
97
+ * 给 CC 发 /exit 然后重启(带 --continue)。
98
+ * guardian 升级后自动重启 CC 用这个。
99
+ */
100
+ function restartCC(session, { socketPath, continueSession = true } = {}) {
101
+ const tmux = tmuxCmd(socketPath);
102
+ // 发 /exit
103
+ execSync(`${tmux} send-keys -t "${session}" -l '/exit'`, EXEC_OPTS);
104
+ execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
105
+ // 等 CC 退出
106
+ return new Promise((resolve) => {
107
+ setTimeout(() => {
108
+ try {
109
+ const cmd = continueSession
110
+ ? 'claude --dangerously-skip-permissions --continue'
111
+ : 'claude --dangerously-skip-permissions';
112
+ execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(cmd)}`, EXEC_OPTS);
113
+ execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
114
+ resolve(true);
115
+ } catch (e) {
116
+ resolve(false);
117
+ }
118
+ }, 3000);
119
+ });
120
+ }
121
+
122
+ module.exports = { listSessions, isCCRunning, sendToCC, readOutput, startCC, restartCC, tmuxCmd };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Host-tool 模式:对所有已注册的 SEAM_HOME 滚动升级 + 重启 guardian。
3
+ *
4
+ * 流程:
5
+ * 1. 全局升 npm 包(仅一次)
6
+ * 2. 读 ~/.seam-homes,遍历每个 SEAM_HOME
7
+ * 3. 每个 SEAM_HOME spawn 子进程 stop → start guardian(隔离 paths.js 缓存)
8
+ * 4. 清掉目录已不存在的 entry
9
+ *
10
+ * 注:本文件是 stage 1 的 registry 实现;stage 2 会替换为 ~/.shared/seam-registry/<userId>.json。
11
+ */
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { execSync, spawnSync } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const CLI_PATH = join(dirname(__filename), '..', 'bin', 'cli.js');
21
+
22
+ export async function upgradeAll() {
23
+ console.log('Seam — host-tool upgrade-all\n');
24
+
25
+ console.log('1. npm install -g @seamnet/client@latest');
26
+ try {
27
+ execSync('npm install -g @seamnet/client@latest', { stdio: 'inherit' });
28
+ } catch (e) {
29
+ console.error(` npm install -g failed: ${e.message}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const homesPath = join(homedir(), '.seam-homes');
34
+ if (!existsSync(homesPath)) {
35
+ console.log('\n2. ~/.seam-homes 不存在——无已注册 AI,结束。');
36
+ return;
37
+ }
38
+ const lines = readFileSync(homesPath, 'utf8').split('\n').filter(Boolean);
39
+ console.log(`\n2. ${lines.length} 个已注册 SEAM_HOME,逐个重启 guardian`);
40
+
41
+ const alive = [];
42
+ for (const home of lines) {
43
+ console.log(`\n → ${home}`);
44
+ if (!existsSync(home)) {
45
+ console.log(' 跳过(目录已不存在,从 registry 移除)');
46
+ continue;
47
+ }
48
+ const env = { ...process.env, SEAM_HOME: home };
49
+ const stopRes = spawnSync('node', [CLI_PATH, 'stop'], { env, stdio: 'inherit' });
50
+ if (stopRes.status !== 0) {
51
+ console.log(' stop 非零退出(guardian 可能未运行,继续)');
52
+ }
53
+ const startRes = spawnSync('node', [CLI_PATH, 'guardian', 'start'], { env, stdio: 'inherit' });
54
+ if (startRes.status !== 0) {
55
+ console.error(` start 失败 (exit ${startRes.status})`);
56
+ } else {
57
+ alive.push(home);
58
+ }
59
+ }
60
+
61
+ if (alive.length !== lines.length) {
62
+ writeFileSync(homesPath, alive.length ? alive.join('\n') + '\n' : '');
63
+ console.log(`\n registry 清理:${lines.length} → ${alive.length}`);
64
+ }
65
+
66
+ console.log(`\nDone. ${alive.length} 个 AI guardian 已重启。`);
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.14.5",
3
+ "version": "0.16.0",
4
4
  "description": "One command to join Seam — the network where people and AI stay in sync.",
5
5
  "bin": {
6
6
  "seam-client": "bin/cli.js",