@seamnet/client 0.14.5 → 0.15.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/seam.js CHANGED
@@ -377,43 +377,11 @@ async function cmdInvite(subAction, restArgs) {
377
377
 
378
378
  // === cc (Claude Code 管理) ===
379
379
 
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
- }
380
+ const tmuxUtils = require('../lib/tmux-utils.cjs');
413
381
 
414
382
  async function cmdCc(subAction, restArgs) {
415
383
  if (!subAction || subAction === 'list') {
416
- const sessions = ccGetSessions();
384
+ const sessions = tmuxUtils.listSessions();
417
385
  if (sessions.length === 0) output(true, { sessions: [], message: 'no tmux sessions' });
418
386
  output(true, { sessions });
419
387
  return;
@@ -430,10 +398,7 @@ async function cmdCc(subAction, restArgs) {
430
398
  });
431
399
  if (!values.session) output(false, '--session required');
432
400
  try {
433
- const text = execSync(
434
- `tmux capture-pane -t "${values.session}" -p | tail -${parseInt(values.lines) || 20}`,
435
- { encoding: 'utf8', timeout: 5000 }
436
- );
401
+ const text = tmuxUtils.readOutput(values.session, parseInt(values.lines) || 20);
437
402
  output(true, { session: values.session, lines: text.trimEnd().split('\n') });
438
403
  } catch (e) {
439
404
  output(false, `read failed: ${e.message}`);
@@ -452,24 +417,11 @@ async function cmdCc(subAction, restArgs) {
452
417
  });
453
418
  if (!values.session) output(false, '--session required');
454
419
  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
420
  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
- );
421
+ tmuxUtils.sendToCC(values.session, values.text);
470
422
  output(true, { session: values.session, sent: values.text });
471
423
  } catch (e) {
472
- output(false, `send failed: ${e.message}`);
424
+ output(false, e.message);
473
425
  }
474
426
  return;
475
427
  }
@@ -484,21 +436,12 @@ async function cmdCc(subAction, restArgs) {
484
436
  strict: false,
485
437
  });
486
438
  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
- }
439
+ const sessionName = values.session || values.dir.split('/').pop();
494
440
  try {
495
- execSync(
496
- `tmux new-session -d -s "${sessionName}" -c "${values.dir}" 'claude --dangerously-skip-permissions'`,
497
- { timeout: 10000 }
498
- );
441
+ tmuxUtils.startCC(values.dir, sessionName);
499
442
  output(true, { session: sessionName, dir: values.dir, started: true });
500
443
  } catch (e) {
501
- output(false, `start failed: ${e.message}`);
444
+ output(false, e.message);
502
445
  }
503
446
  return;
504
447
  }
package/lib/guardian.js CHANGED
@@ -219,29 +219,21 @@ export async function guardianRun() {
219
219
  ? '🔄 [Seam] 升级完成。Guardian 将在 10 秒后重启 CC 以加载新的 MCP 工具。你会在新对话里看到单个 seam 工具取代原来的多个工具。'
220
220
  : '🔄 [Seam] 入网完成。Guardian 将在 10 秒后重启 CC 以加载 MCP 工具。重启后你会在新的对话里读到 IDENTITY.md。';
221
221
  hub.inject(notice);
222
- setTimeout(() => {
222
+ setTimeout(async () => {
223
223
  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);
224
+ const { restartCC } = require('./tmux-utils.cjs');
225
+ const ok = await restartCC(safeSession, { socketPath: ccSocket, continueSession: true });
226
+ if (ok) {
227
+ guardianState.set('cc_restarted', new Date().toISOString());
228
+ if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
229
+ hub.logger('guardian').info('cc_restart_injected');
230
+ } else {
231
+ hub.logger('guardian').error('cc_restart_failed', new Error('restartCC returned false'));
232
+ }
241
233
  } catch (e) {
242
- hub.logger('guardian').error('cc_exit_failed', e);
234
+ hub.logger('guardian').error('cc_restart_failed', e);
243
235
  }
244
- }, 10000); // 3s → 10s 给 AI 读到通知的时间
236
+ }, 10000);
245
237
  }
246
238
 
247
239
  // Keep alive + graceful shutdown (pid 文件清理)
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.14.5",
3
+ "version": "0.15.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",