@seamnet/client 0.18.2 → 0.20.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 +70 -23
- package/lib/guardian.js +3 -3
- package/lib/init.js +7 -5
- package/lib/tell.js +3 -3
- package/lib/tmux-utils.cjs +182 -94
- package/lib/upgrade-all.js +45 -31
- package/lib/upgrade.js +176 -89
- package/package.json +1 -1
package/bin/seam.js
CHANGED
|
@@ -57,11 +57,12 @@ function printHelp() {
|
|
|
57
57
|
' seam self schedule --id <id> --every <duration> --text <text>',
|
|
58
58
|
' seam self cancel --id <id>',
|
|
59
59
|
' seam self list',
|
|
60
|
-
' seam
|
|
61
|
-
' seam
|
|
62
|
-
' seam
|
|
63
|
-
' seam
|
|
64
|
-
' seam
|
|
60
|
+
' seam term list',
|
|
61
|
+
' seam term read --session <name> [--lines N | --full]',
|
|
62
|
+
' seam term send --session <name> [--text <msg> [--no-enter]] [--key <K> ...]',
|
|
63
|
+
' seam term start --kind cc|codex|shell [--session <name>] [--dir <path>] [--cmd "..."]',
|
|
64
|
+
' seam term stop --session <name>',
|
|
65
|
+
' seam term restart --session <name> [--no-continue]',
|
|
65
66
|
'',
|
|
66
67
|
'输出:JSON { ok: true, data } 或 { ok: false, error }。',
|
|
67
68
|
].join('\n');
|
|
@@ -401,15 +402,13 @@ async function cmdInvite(subAction, restArgs) {
|
|
|
401
402
|
output(false, `Unknown invite action: ${subAction}. Try: seam invite generate`);
|
|
402
403
|
}
|
|
403
404
|
|
|
404
|
-
// ===
|
|
405
|
+
// === term (通用终端操作) ===
|
|
405
406
|
|
|
406
407
|
const tmuxUtils = require('../lib/tmux-utils.cjs');
|
|
407
408
|
|
|
408
|
-
async function
|
|
409
|
+
async function cmdTerm(subAction, restArgs) {
|
|
409
410
|
if (!subAction || subAction === 'list') {
|
|
410
|
-
|
|
411
|
-
if (sessions.length === 0) output(true, { sessions: [], message: 'no tmux sessions' });
|
|
412
|
-
output(true, { sessions });
|
|
411
|
+
output(true, { terms: tmuxUtils.listTerms() });
|
|
413
412
|
return;
|
|
414
413
|
}
|
|
415
414
|
|
|
@@ -419,12 +418,16 @@ async function cmdCc(subAction, restArgs) {
|
|
|
419
418
|
options: {
|
|
420
419
|
session: { type: 'string' },
|
|
421
420
|
lines: { type: 'string', default: '20' },
|
|
421
|
+
full: { type: 'boolean' },
|
|
422
422
|
},
|
|
423
423
|
strict: false,
|
|
424
424
|
});
|
|
425
425
|
if (!values.session) output(false, '--session required');
|
|
426
426
|
try {
|
|
427
|
-
const text = tmuxUtils.
|
|
427
|
+
const text = tmuxUtils.readTerm(values.session, {
|
|
428
|
+
lines: parseInt(values.lines, 10) || 20,
|
|
429
|
+
full: !!values.full,
|
|
430
|
+
});
|
|
428
431
|
output(true, { session: values.session, lines: text.trimEnd().split('\n') });
|
|
429
432
|
} catch (e) {
|
|
430
433
|
output(false, `read failed: ${e.message}`);
|
|
@@ -438,14 +441,23 @@ async function cmdCc(subAction, restArgs) {
|
|
|
438
441
|
options: {
|
|
439
442
|
session: { type: 'string' },
|
|
440
443
|
text: { type: 'string' },
|
|
444
|
+
'no-enter': { type: 'boolean' },
|
|
445
|
+
key: { type: 'string', multiple: true },
|
|
441
446
|
},
|
|
442
447
|
strict: false,
|
|
443
448
|
});
|
|
444
449
|
if (!values.session) output(false, '--session required');
|
|
445
|
-
|
|
450
|
+
const keys = values.key || [];
|
|
451
|
+
if (values.text == null && keys.length === 0) {
|
|
452
|
+
output(false, '需要 --text 或 --key 至少一个');
|
|
453
|
+
}
|
|
446
454
|
try {
|
|
447
|
-
tmuxUtils.
|
|
448
|
-
|
|
455
|
+
tmuxUtils.sendTerm(values.session, {
|
|
456
|
+
text: values.text,
|
|
457
|
+
noEnter: !!values['no-enter'],
|
|
458
|
+
keys,
|
|
459
|
+
});
|
|
460
|
+
output(true, { session: values.session, text: values.text ?? null, keys });
|
|
449
461
|
} catch (e) {
|
|
450
462
|
output(false, e.message);
|
|
451
463
|
}
|
|
@@ -456,16 +468,51 @@ async function cmdCc(subAction, restArgs) {
|
|
|
456
468
|
const { values } = parseArgs({
|
|
457
469
|
args: restArgs,
|
|
458
470
|
options: {
|
|
459
|
-
|
|
471
|
+
kind: { type: 'string' },
|
|
460
472
|
session: { type: 'string' },
|
|
473
|
+
dir: { type: 'string' },
|
|
474
|
+
cmd: { type: 'string' },
|
|
461
475
|
},
|
|
462
476
|
strict: false,
|
|
463
477
|
});
|
|
464
|
-
if (!values.
|
|
465
|
-
const sessionName = values.session || values.dir.split('/').pop();
|
|
478
|
+
if (!values.kind) output(false, '--kind required (cc|codex|shell)');
|
|
466
479
|
try {
|
|
467
|
-
tmuxUtils.
|
|
468
|
-
|
|
480
|
+
const res = tmuxUtils.startTerm({
|
|
481
|
+
kind: values.kind,
|
|
482
|
+
cmd: values.cmd,
|
|
483
|
+
session: values.session,
|
|
484
|
+
dir: values.dir,
|
|
485
|
+
});
|
|
486
|
+
output(true, { ...res, started: true });
|
|
487
|
+
} catch (e) {
|
|
488
|
+
output(false, e.message);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (subAction === 'stop') {
|
|
494
|
+
const { values } = parseArgs({
|
|
495
|
+
args: restArgs,
|
|
496
|
+
options: { session: { type: 'string' } },
|
|
497
|
+
strict: false,
|
|
498
|
+
});
|
|
499
|
+
if (!values.session) output(false, '--session required');
|
|
500
|
+
// 关之前查 registry:这个 session 是否绑着一个 guardian
|
|
501
|
+
let guardianEntry = null;
|
|
502
|
+
try {
|
|
503
|
+
const { readAll } = await import('../lib/registry.js');
|
|
504
|
+
guardianEntry = readAll().find((e) => e.tmux_session === values.session) || null;
|
|
505
|
+
} catch {}
|
|
506
|
+
try {
|
|
507
|
+
const { kind } = tmuxUtils.stopTerm(values.session);
|
|
508
|
+
const result = { session: values.session, kind, stopped: true };
|
|
509
|
+
if (guardianEntry) {
|
|
510
|
+
result.guardian_killed = true;
|
|
511
|
+
result.warning = `注意:已一并终止该 home 的 guardian(userId=${guardianEntry.userId}, home=${guardianEntry.seam_home})`;
|
|
512
|
+
} else if (kind === 'cc') {
|
|
513
|
+
result.warning = '注意:这是个 cc 会话——若它绑着 guardian,guardian 已随会话一并终止';
|
|
514
|
+
}
|
|
515
|
+
output(true, result);
|
|
469
516
|
} catch (e) {
|
|
470
517
|
output(false, e.message);
|
|
471
518
|
}
|
|
@@ -485,11 +532,11 @@ async function cmdCc(subAction, restArgs) {
|
|
|
485
532
|
if (!values.session) output(false, '--session required');
|
|
486
533
|
const continueSession = values['no-continue'] ? false : true;
|
|
487
534
|
try {
|
|
488
|
-
const ok = await tmuxUtils.
|
|
535
|
+
const ok = await tmuxUtils.restartTerm(values.session, { continueSession });
|
|
489
536
|
if (ok) {
|
|
490
537
|
output(true, { session: values.session, restarted: true });
|
|
491
538
|
} else {
|
|
492
|
-
output(false, 'restart failed:
|
|
539
|
+
output(false, 'restart failed: 终端未在超时内退出或重启');
|
|
493
540
|
}
|
|
494
541
|
} catch (e) {
|
|
495
542
|
output(false, e.message);
|
|
@@ -497,7 +544,7 @@ async function cmdCc(subAction, restArgs) {
|
|
|
497
544
|
return;
|
|
498
545
|
}
|
|
499
546
|
|
|
500
|
-
output(false, `Unknown
|
|
547
|
+
output(false, `Unknown term action: ${subAction}. Try: list, read, send, start, stop, restart`);
|
|
501
548
|
}
|
|
502
549
|
|
|
503
550
|
function parseDuration(str) {
|
|
@@ -529,7 +576,7 @@ function parseDuration(str) {
|
|
|
529
576
|
if (domain === 'inbox') return await cmdInbox(action, rest);
|
|
530
577
|
if (domain === 'self') return await cmdSelf(action, rest);
|
|
531
578
|
if (domain === 'invite') return await cmdInvite(action, rest);
|
|
532
|
-
if (domain === '
|
|
579
|
+
if (domain === 'term') return await cmdTerm(action, rest);
|
|
533
580
|
output(false, `Unknown domain: ${domain}. Try: seam --help`);
|
|
534
581
|
} catch (e) {
|
|
535
582
|
output(false, e.message);
|
package/lib/guardian.js
CHANGED
|
@@ -252,14 +252,14 @@ export async function guardianRun() {
|
|
|
252
252
|
hub.inject(notice);
|
|
253
253
|
setTimeout(async () => {
|
|
254
254
|
try {
|
|
255
|
-
const {
|
|
256
|
-
const ok = await
|
|
255
|
+
const { restartTerm } = require('./tmux-utils.cjs');
|
|
256
|
+
const ok = await restartTerm(safeSession, { socketPath: ccSocket, continueSession: true });
|
|
257
257
|
if (ok) {
|
|
258
258
|
guardianState.set('cc_restarted', new Date().toISOString());
|
|
259
259
|
if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
|
|
260
260
|
hub.logger('guardian').info('cc_restart_injected');
|
|
261
261
|
} else {
|
|
262
|
-
hub.logger('guardian').error('cc_restart_failed', new Error('
|
|
262
|
+
hub.logger('guardian').error('cc_restart_failed', new Error('restartTerm returned false'));
|
|
263
263
|
}
|
|
264
264
|
} catch (e) {
|
|
265
265
|
hub.logger('guardian').error('cc_restart_failed', e);
|
package/lib/init.js
CHANGED
|
@@ -225,8 +225,9 @@ function registerSeamHome() {
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
export function patchClaudeMd() {
|
|
229
|
-
|
|
228
|
+
export function patchClaudeMd(projectDir = process.cwd()) {
|
|
229
|
+
// 默认值 process.cwd() 仅对当前 home 有效;跨 home 调用必须显式传 projectDir
|
|
230
|
+
const claudeMdPath = join(projectDir, 'CLAUDE.md');
|
|
230
231
|
const rulesRef = '@.seam/CHANNEL_RULES.md';
|
|
231
232
|
const identityRef = '@.seam/IDENTITY.md';
|
|
232
233
|
const contactsRef = '@.seam/contacts.json';
|
|
@@ -260,10 +261,11 @@ export function patchClaudeMd() {
|
|
|
260
261
|
* 从 npm package 的 templates/ 拷贝 CHANNEL_RULES.md 到 .seam/。
|
|
261
262
|
* 每次 init 和 guardian 启动都覆盖——确保老 AI 升级后获得最新规则。
|
|
262
263
|
*/
|
|
263
|
-
export function syncChannelRules() {
|
|
264
|
+
export function syncChannelRules(seamHome = SEAM_DIR) {
|
|
265
|
+
// 默认值 SEAM_DIR 仅对当前 home 有效;跨 home 调用必须显式传 seamHome
|
|
264
266
|
const src = join(TEMPLATES_DIR, 'CHANNEL_RULES.md');
|
|
265
|
-
const dest = join(
|
|
266
|
-
if (!existsSync(
|
|
267
|
+
const dest = join(seamHome, 'CHANNEL_RULES.md');
|
|
268
|
+
if (!existsSync(seamHome)) mkdirSync(seamHome, { recursive: true });
|
|
267
269
|
const content = readFileSync(src, 'utf8');
|
|
268
270
|
writeFileSync(dest, content);
|
|
269
271
|
return dest;
|
package/lib/tell.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* seam tell:跨 AI 单条投递。
|
|
3
3
|
*
|
|
4
|
-
* 优先走 cross-AI registry —— 目标 AI 在本机 → 直接调 tmux
|
|
4
|
+
* 优先走 cross-AI registry —— 目标 AI 在本机 → 直接调 tmux sendTerm 注入;
|
|
5
5
|
* 不在或 socket 死 → fallback 走 IM 发到对方 userId。
|
|
6
6
|
*
|
|
7
7
|
* 入站 inject 加 from header:`💬 [本机 ← <from>] <text>`
|
|
@@ -40,9 +40,9 @@ export async function tell({ to, from, text, guardianRequest }) {
|
|
|
40
40
|
const entry = findByAlias(to);
|
|
41
41
|
if (entry?.tmux_session) {
|
|
42
42
|
try {
|
|
43
|
-
const {
|
|
43
|
+
const { sendTerm } = require('./tmux-utils.cjs');
|
|
44
44
|
const injectText = formatLocalInject(fromName, text);
|
|
45
|
-
|
|
45
|
+
sendTerm(entry.tmux_session, { text: injectText, socketPath: entry.tmux_socket || undefined });
|
|
46
46
|
return { ok: true, channel: 'local', target: entry.userId };
|
|
47
47
|
} catch (e) {
|
|
48
48
|
// 本机命中但 CC 不在线 / tmux session 死了 → 落 IM
|
package/lib/tmux-utils.cjs
CHANGED
|
@@ -1,157 +1,245 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tmux-utils —
|
|
2
|
+
* tmux-utils — 通用 tmux 终端操作(transport 层)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 只做 transport:读屏(capture-pane)、发键(send-keys)、起/停/重启会话。
|
|
5
|
+
* 这层与 pane 里跑什么无关。交互语义(审批流、idle 判断等)故意不抽象——
|
|
6
|
+
* 由操作者读屏自己决定按什么键。
|
|
7
|
+
*
|
|
8
|
+
* 唯一的 per-kind 知识:KIND_CMD 表(kind → 默认启动命令);restart 时
|
|
9
|
+
* cc 走优雅 /exit(跑 Stop hook 保命),codex 走 kill 重起。
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
const { execSync } = require('node:child_process');
|
|
11
|
-
const { existsSync
|
|
12
|
-
const {
|
|
13
|
+
const { existsSync } = require('node:fs');
|
|
14
|
+
const { basename } = require('node:path');
|
|
13
15
|
|
|
14
16
|
const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
15
17
|
|
|
18
|
+
// kind → 默认启动命令。新增 kind 在这里加一行即可(kind 是数据,不是命令树)。
|
|
19
|
+
const KIND_CMD = {
|
|
20
|
+
cc: 'claude --dangerously-skip-permissions',
|
|
21
|
+
codex: 'codex -m gpt-5.4',
|
|
22
|
+
shell: 'bash',
|
|
23
|
+
};
|
|
24
|
+
const KIND_PREFIX = { cc: 'cc', codex: 'codex', shell: 'sh' };
|
|
25
|
+
|
|
16
26
|
function tmuxCmd(socketPath) {
|
|
17
27
|
return socketPath ? `tmux -S "${socketPath}"` : 'tmux';
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
function
|
|
30
|
+
function sleep(ms) {
|
|
31
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 检测 session 里跑的是什么:'cc' | 'codex' | 'shell'。session 不存在返回 null。
|
|
36
|
+
* 按 pane 进程及其子进程的 comm 判断。
|
|
37
|
+
*/
|
|
38
|
+
function detectKind(session, socketPath) {
|
|
21
39
|
const tmux = tmuxCmd(socketPath);
|
|
22
|
-
let
|
|
40
|
+
let panePid;
|
|
23
41
|
try {
|
|
24
|
-
|
|
25
|
-
.trim().split('\n').filter(Boolean);
|
|
42
|
+
panePid = execSync(`${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS).trim();
|
|
26
43
|
} catch {
|
|
27
|
-
return
|
|
44
|
+
return null;
|
|
28
45
|
}
|
|
29
|
-
|
|
30
|
-
const cc = isCCRunning(name, socketPath);
|
|
31
|
-
return { session: name, cc };
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isCCRunning(session, socketPath) {
|
|
36
|
-
const tmux = tmuxCmd(socketPath);
|
|
46
|
+
const comms = [];
|
|
37
47
|
try {
|
|
38
|
-
|
|
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
|
+
comms.push(execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, EXEC_OPTS).trim());
|
|
48
49
|
} catch {}
|
|
49
|
-
|
|
50
|
+
try {
|
|
51
|
+
const children = execSync(`ps -o comm= --ppid ${panePid} 2>/dev/null`, EXEC_OPTS).trim();
|
|
52
|
+
if (children) comms.push(children);
|
|
53
|
+
} catch {}
|
|
54
|
+
const joined = comms.join(' ').toLowerCase();
|
|
55
|
+
if (joined.includes('claude')) return 'cc';
|
|
56
|
+
if (joined.includes('codex')) return 'codex';
|
|
57
|
+
return 'shell';
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
/**
|
|
53
|
-
*
|
|
54
|
-
* 用 -l (literal) 发文本 + 单独 Enter,保证 raw terminal mode 下不丢回车。
|
|
61
|
+
* 列出所有 tmux 会话 + 每个的 kind。
|
|
55
62
|
*/
|
|
56
|
-
function
|
|
63
|
+
function listTerms(socketPath) {
|
|
57
64
|
const tmux = tmuxCmd(socketPath);
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
let lines;
|
|
66
|
+
try {
|
|
67
|
+
lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
|
|
68
|
+
.trim().split('\n').filter(Boolean);
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
60
71
|
}
|
|
61
|
-
|
|
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);
|
|
72
|
+
return lines.map((name) => ({ session: name, kind: detectKind(name, socketPath) }));
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
|
-
* 读
|
|
76
|
+
* 读 session 屏幕。默认 last N 行;full=true 读整屏 + scrollback 历史。
|
|
77
|
+
*
|
|
78
|
+
* 实现:一律 `capture-pane -p -S -` 抓全量,在 JS 里去掉尾部空行再切最后 N 行。
|
|
79
|
+
* 不用 shell `| tail`——capture-pane 会把光标下方的空 viewport 行也输出,
|
|
80
|
+
* shell 的 tail 先抓到那些空行,内容稀疏的会话(shell / 刚起的 codex)就读不到东西。
|
|
81
|
+
* capture-pane 受 tmux history-limit 限制(默认 2000 行),输出有界。
|
|
69
82
|
*/
|
|
70
|
-
function
|
|
83
|
+
function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
|
|
71
84
|
const tmux = tmuxCmd(socketPath);
|
|
72
|
-
|
|
73
|
-
`${tmux} capture-pane -t "${session}" -p
|
|
74
|
-
EXEC_OPTS
|
|
85
|
+
const raw = execSync(
|
|
86
|
+
`${tmux} capture-pane -t "${session}" -p -S -`,
|
|
87
|
+
{ ...EXEC_OPTS, maxBuffer: 16 * 1024 * 1024 }
|
|
75
88
|
);
|
|
89
|
+
if (full) return raw;
|
|
90
|
+
const n = parseInt(lines, 10) || 20;
|
|
91
|
+
return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
/**
|
|
79
|
-
*
|
|
95
|
+
* 给 session 发文本和/或命名按键。
|
|
96
|
+
* - text:字面文本(-l)。默认末尾自动 Enter;noEnter 或同时有 keys 时抑制自动 Enter。
|
|
97
|
+
* - keys:tmux 命名按键(C-c / Enter / Escape / Up / Tab ...),按顺序发,不加 Enter。
|
|
98
|
+
* 二者可叠加:text 先发,再按顺序发 keys。
|
|
80
99
|
*/
|
|
81
|
-
function
|
|
100
|
+
function sendTerm(session, { text, noEnter = false, keys = [], socketPath } = {}) {
|
|
82
101
|
const tmux = tmuxCmd(socketPath);
|
|
83
|
-
|
|
102
|
+
const hasText = text != null && text !== '';
|
|
103
|
+
const hasKeys = Array.isArray(keys) && keys.length > 0;
|
|
104
|
+
if (!hasText && !hasKeys) {
|
|
105
|
+
throw new Error('sendTerm: 需要 text 或 keys 至少一个');
|
|
106
|
+
}
|
|
107
|
+
if (hasText) {
|
|
108
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
|
|
109
|
+
// 自动 Enter:仅 text-only 且未抑制时。混发按键 = 手动模式,不惊喜回车。
|
|
110
|
+
if (!noEnter && !hasKeys) {
|
|
111
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const k of (hasKeys ? keys : [])) {
|
|
115
|
+
execSync(`${tmux} send-keys -t "${session}" ${JSON.stringify(k)}`, EXEC_OPTS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 新建 tmux 会话并在里面起东西。会话名带 kind 前缀(cc-/codex-/sh-)。
|
|
121
|
+
* 会话先跑 bash,再 send 启动命令进去——这样程序退出后 bash 还在、
|
|
122
|
+
* 会话不死,restart 能在原会话里重起。
|
|
123
|
+
*/
|
|
124
|
+
function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
|
|
125
|
+
const tmux = tmuxCmd(socketPath);
|
|
126
|
+
if (!kind || !(kind in KIND_CMD)) {
|
|
127
|
+
throw new Error(`startTerm: --kind 必须是 ${Object.keys(KIND_CMD).join('|')}`);
|
|
128
|
+
}
|
|
129
|
+
if (dir && !existsSync(dir)) throw new Error(`dir not found: ${dir}`);
|
|
130
|
+
const base = session || (dir ? basename(dir) : null);
|
|
131
|
+
if (!base) throw new Error('startTerm: 需要 --session 或 --dir');
|
|
132
|
+
const sessionName = `${KIND_PREFIX[kind]}-${base}`;
|
|
84
133
|
try {
|
|
85
|
-
execSync(`${tmux} has-session -t "${
|
|
86
|
-
throw new Error(`session "${
|
|
134
|
+
execSync(`${tmux} has-session -t "${sessionName}" 2>/dev/null`, EXEC_OPTS);
|
|
135
|
+
throw new Error(`session "${sessionName}" already exists`);
|
|
87
136
|
} catch (e) {
|
|
88
137
|
if (e.message.includes('already exists')) throw e;
|
|
89
138
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
const cwdArg = dir ? `-c "${dir}"` : '';
|
|
140
|
+
execSync(`${tmux} new-session -d -s "${sessionName}" ${cwdArg}`.trim(), { ...EXEC_OPTS, timeout: 10000 });
|
|
141
|
+
// kind=shell 且无 --cmd:new-session 本身就是 bash,不必再 send。
|
|
142
|
+
const startCmd = cmd || KIND_CMD[kind];
|
|
143
|
+
if (cmd || kind !== 'shell') {
|
|
144
|
+
execSync(`${tmux} send-keys -t "${sessionName}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
145
|
+
execSync(`${tmux} send-keys -t "${sessionName}" Enter`, EXEC_OPTS);
|
|
146
|
+
}
|
|
147
|
+
return { session: sessionName, kind, cmd: startCmd };
|
|
98
148
|
}
|
|
99
149
|
|
|
100
150
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* 不再固定等 3s——有 Stop hook(auto-save / semantic_memory)的 AI 退出要
|
|
105
|
-
* 60-90s,3s 时旧 claude 还活着,重启命令会被当成旧 TUI 的 queued message,
|
|
106
|
-
* CC 永远没重启。改成轮询 isCCRunning 确认状态再动作。
|
|
151
|
+
* restart 通用骨架:停 → 轮询确认停了 → 起 → 轮询确认起了 → 失败 C-c 重试一次。
|
|
152
|
+
* 0.17.1 的「别在状态真变之前动作」教训对所有 kind 通用——差别只在「怎么停」。
|
|
107
153
|
*/
|
|
108
|
-
async function
|
|
154
|
+
async function restartSkeleton(session, socketPath, { targetKind, stopAction, downTimeout, startCmd }) {
|
|
109
155
|
const tmux = tmuxCmd(socketPath);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
execSync(`${tmux} send-keys -t "${session}" -l '/exit'`, EXEC_OPTS);
|
|
116
|
-
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
117
|
-
|
|
118
|
-
// 2. 轮询直到 CC 退出(120s 覆盖 90s Stop hook + 余量)
|
|
119
|
-
const exitDeadline = Date.now() + 120000;
|
|
120
|
-
while (isCCRunning(session, socketPath)) {
|
|
121
|
-
if (Date.now() >= exitDeadline) {
|
|
122
|
-
// 旧 CC 还没退,发重启命令会变成 queued message 污染 TUI——放弃
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
156
|
+
stopAction();
|
|
157
|
+
// poll-down:轮询直到 kind 不再是 targetKind
|
|
158
|
+
const downDeadline = Date.now() + downTimeout;
|
|
159
|
+
while (detectKind(session, socketPath) === targetKind) {
|
|
160
|
+
if (Date.now() >= downDeadline) return false;
|
|
125
161
|
await sleep(1000);
|
|
126
162
|
}
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
await sleep(500);
|
|
130
|
-
|
|
131
|
-
// 4-5. 发重启命令 + 轮询确认 CC 起来(30s 超时)
|
|
163
|
+
await sleep(500); // 等 shell prompt 落定
|
|
164
|
+
// 起 + poll-up(30s)
|
|
132
165
|
const tryStart = async () => {
|
|
133
166
|
try {
|
|
134
|
-
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(
|
|
167
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
135
168
|
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
136
169
|
} catch {
|
|
137
170
|
return false;
|
|
138
171
|
}
|
|
139
172
|
const upDeadline = Date.now() + 30000;
|
|
140
|
-
while (
|
|
173
|
+
while (detectKind(session, socketPath) !== targetKind) {
|
|
141
174
|
if (Date.now() >= upDeadline) return false;
|
|
142
175
|
await sleep(1000);
|
|
143
176
|
}
|
|
144
177
|
return true;
|
|
145
178
|
};
|
|
146
|
-
|
|
147
179
|
if (await tryStart()) return true;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS);
|
|
152
|
-
} catch {}
|
|
180
|
+
// 没起来:C-c 清掉可能的半行,重试一次
|
|
181
|
+
try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
|
|
153
182
|
await sleep(500);
|
|
154
183
|
return tryStart();
|
|
155
184
|
}
|
|
156
185
|
|
|
157
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 重启 session 里跑的东西。只对 cc / codex 有意义——非 cc/codex(shell 或
|
|
188
|
+
* 自定义命令)直接报错,不静默重启成 bash。
|
|
189
|
+
*
|
|
190
|
+
* cc:优雅 /exit(跑 Stop hook,120s 超时覆盖 auto-save/semantic_memory)。
|
|
191
|
+
* codex:kill 进程(30s 短超时)。两者都轮询确认停了再起。
|
|
192
|
+
*/
|
|
193
|
+
async function restartTerm(session, { socketPath, continueSession = true } = {}) {
|
|
194
|
+
const tmux = tmuxCmd(socketPath);
|
|
195
|
+
const kind = detectKind(session, socketPath);
|
|
196
|
+
if (kind !== 'cc' && kind !== 'codex') {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`"${session}" 不是 cc/codex 会话(检测到 kind=${kind === null ? '会话不存在' : kind}),`
|
|
199
|
+
+ 'restart 不知道该用什么命令重启它。要重起请用 `seam term start --kind ... --cmd "..."`。'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (kind === 'cc') {
|
|
203
|
+
const startCmd = continueSession ? `${KIND_CMD.cc} --continue` : KIND_CMD.cc;
|
|
204
|
+
return restartSkeleton(session, socketPath, {
|
|
205
|
+
targetKind: 'cc',
|
|
206
|
+
// cc 必须优雅退:发 /exit 跑 Stop hook,kill 会丢 AI 记忆
|
|
207
|
+
stopAction: () => {
|
|
208
|
+
execSync(`${tmux} send-keys -t "${session}" -l '/exit'`, EXEC_OPTS);
|
|
209
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
210
|
+
},
|
|
211
|
+
downTimeout: 120000,
|
|
212
|
+
startCmd,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// codex:kill pane 进程的子进程(codex),bash 留着,会话不死
|
|
216
|
+
return restartSkeleton(session, socketPath, {
|
|
217
|
+
targetKind: 'codex',
|
|
218
|
+
stopAction: () => {
|
|
219
|
+
try {
|
|
220
|
+
const panePid = execSync(
|
|
221
|
+
`${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS
|
|
222
|
+
).trim();
|
|
223
|
+
execSync(`pkill -TERM -P ${panePid} 2>/dev/null || true`, EXEC_OPTS);
|
|
224
|
+
} catch {}
|
|
225
|
+
},
|
|
226
|
+
downTimeout: 30000,
|
|
227
|
+
startCmd: KIND_CMD.codex,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 关掉整个终端(kill-session)。返回关之前检测到的 kind(供调用方判断是否
|
|
233
|
+
* 连带杀了 guardian)。
|
|
234
|
+
*/
|
|
235
|
+
function stopTerm(session, { socketPath } = {}) {
|
|
236
|
+
const tmux = tmuxCmd(socketPath);
|
|
237
|
+
const kind = detectKind(session, socketPath);
|
|
238
|
+
execSync(`${tmux} kill-session -t "${session}"`, EXEC_OPTS);
|
|
239
|
+
return { kind };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
tmuxCmd, detectKind, listTerms, readTerm, sendTerm,
|
|
244
|
+
startTerm, restartTerm, stopTerm, KIND_CMD,
|
|
245
|
+
};
|
package/lib/upgrade-all.js
CHANGED
|
@@ -1,31 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host-tool 模式:对所有已注册的 SEAM_HOME
|
|
2
|
+
* Host-tool 模式:对所有已注册的 SEAM_HOME 跑完整 applyHomeUpgrade。
|
|
3
3
|
*
|
|
4
4
|
* 流程:
|
|
5
5
|
* 1. 全局升 npm 包(仅一次)
|
|
6
6
|
* 2. 读 ~/.shared/seam-registry/*.json(当前在跑的 guardian 列表)
|
|
7
|
-
* 3.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* 3. 逐个 entry 跑完整 applyHomeUpgrade(version.json / patch settings /
|
|
8
|
+
* syncChannelRules / patchClaudeMd / 剥 .mcp.json / 重启 guardian)。
|
|
9
|
+
* 顺序执行,每个 entry try/catch——一个 home 失败不中断其余。
|
|
10
|
+
* 4. 末尾报告 ok/fail 计数 + 每个 home 结果。
|
|
10
11
|
*
|
|
11
12
|
* 仅升级"当前在跑的 guardian"——目录还在但 guardian 没开的,跳过。
|
|
12
|
-
* 想拉那些上来,下次自己 `seam-client guardian start`
|
|
13
|
-
* 那时已是新版 npm 包。
|
|
14
|
-
*
|
|
13
|
+
* 想拉那些上来,下次自己 `seam-client guardian start` 即可(已是新版包)。
|
|
15
14
|
* 路人 CC(没装 seam-client / 没 register)天然不在 registry,不会被碰。
|
|
16
15
|
*/
|
|
17
16
|
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
|
-
import { execSync
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
21
20
|
import { readAll } from './registry.js';
|
|
21
|
+
import { applyHomeUpgrade } from './upgrade.js';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 检测某 home 是否还残留本地 node_modules/@seamnet/client。
|
|
25
|
+
* 只警告,不删——删本地包是单独的迁移决定,由人手动做。
|
|
26
|
+
*/
|
|
27
|
+
function checkLocalInstallResidue(seamHome) {
|
|
28
|
+
const localPkg = join(dirname(seamHome), 'node_modules', '@seamnet', 'client');
|
|
29
|
+
if (existsSync(localPkg)) {
|
|
30
|
+
console.log(
|
|
31
|
+
` ⚠️ ${dirname(seamHome)} 仍有本地包 node_modules/@seamnet/client——`
|
|
32
|
+
+ '全局迁移未完成,建议手动删除本地 node_modules/@seamnet/client'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
25
36
|
|
|
26
37
|
export async function upgradeAll() {
|
|
27
38
|
console.log('Seam — host-tool upgrade-all\n');
|
|
28
39
|
|
|
40
|
+
// 1. 全局升包(一次)
|
|
29
41
|
console.log('1. npm install -g @seamnet/client@latest');
|
|
30
42
|
try {
|
|
31
43
|
execSync('npm install -g @seamnet/client@latest', { stdio: 'inherit' });
|
|
@@ -34,35 +46,37 @@ export async function upgradeAll() {
|
|
|
34
46
|
process.exit(1);
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
// 2. 读 registry
|
|
37
50
|
const entries = readAll();
|
|
38
51
|
if (entries.length === 0) {
|
|
39
52
|
console.log('\n2. registry 为空——没有正在运行的 guardian,结束。');
|
|
40
53
|
return;
|
|
41
54
|
}
|
|
42
|
-
console.log(`\n2. registry 里 ${entries.length} 个活 guardian
|
|
55
|
+
console.log(`\n2. registry 里 ${entries.length} 个活 guardian,逐个跑完整升级`);
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
// 3. 逐个 applyHomeUpgrade(一个失败不中断其余)
|
|
58
|
+
const results = [];
|
|
45
59
|
for (const entry of entries) {
|
|
46
60
|
const { userId, seam_home, tmux_session, tmux_socket } = entry;
|
|
47
|
-
console.log(`\n → ${userId} (home=${seam_home}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.
|
|
58
|
-
|
|
59
|
-
const startRes = spawnSync('node', [CLI_PATH, 'guardian', 'start'], { env, stdio: 'inherit' });
|
|
60
|
-
if (startRes.status !== 0) {
|
|
61
|
-
console.error(` start 失败 (exit ${startRes.status})`);
|
|
62
|
-
} else {
|
|
63
|
-
okCount += 1;
|
|
61
|
+
console.log(`\n → ${userId} (home=${seam_home})`);
|
|
62
|
+
checkLocalInstallResidue(seam_home);
|
|
63
|
+
try {
|
|
64
|
+
await applyHomeUpgrade({
|
|
65
|
+
seamHome: seam_home,
|
|
66
|
+
ccSession: tmux_session,
|
|
67
|
+
ccSocket: tmux_socket,
|
|
68
|
+
});
|
|
69
|
+
results.push({ userId, ok: true });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error(` FAILED: ${e.message}`);
|
|
72
|
+
results.push({ userId, ok: false, error: e.message });
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
// 4. 报告
|
|
77
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
78
|
+
console.log(`\nDone. ${okCount}/${results.length} 个 home 升级成功。`);
|
|
79
|
+
for (const r of results) {
|
|
80
|
+
console.log(` ${r.ok ? 'ok ' : 'FAIL'} ${r.userId}${r.ok ? '' : ' — ' + r.error}`);
|
|
81
|
+
}
|
|
68
82
|
}
|
package/lib/upgrade.js
CHANGED
|
@@ -1,121 +1,182 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 升级已入网 AI 的 seam-client 配置。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 4. 重启 guardian(新 detached 后台进程生效)
|
|
4
|
+
* applyHomeUpgrade({ seamHome, ccSession, ccSocket })
|
|
5
|
+
* —— 对指定 home 做 per-home 升级:写 version.json、patch settings.json、
|
|
6
|
+
* syncChannelRules、patchClaudeMd、剥 .mcp.json 的 seam-im、
|
|
7
|
+
* 置 pending_upgrade_restart、spawn 重启该 home 的 guardian。
|
|
8
|
+
* 不含 npm install——caller 先装好包。所有路径基于 seamHome 参数,
|
|
9
|
+
* 不碰 process.cwd()、不碰 paths.js 的 SEAM_DIR 常量。
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* upgrade() —— per-project 命令:本地 npm install + applyHomeUpgrade(当前 home)。
|
|
12
|
+
*
|
|
13
|
+
* 不做:不动 credentials.json / IDENTITY.md(AI 自己的),不重新 register。
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import {
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
20
20
|
import { SEAM_DIR } from './paths.js';
|
|
21
|
+
import { resolveTmuxSocketPath } from './guardian.js';
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
console.log('Seam — upgrading...\n');
|
|
23
|
+
const CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
execSync('npm install @seamnet/client@latest', { stdio: 'inherit' });
|
|
29
|
-
} catch (e) {
|
|
30
|
-
console.error(` npm install failed: ${e.message}`);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 读 upgrade.js 模块自身相邻的 package.json 版本号。
|
|
31
|
+
* caller 跑完 npm install(local 或 global)后该文件已被覆盖成新版,
|
|
32
|
+
* 此处运行时读取即拿到新版本号——不依赖 cwd/node_modules(host-tool
|
|
33
|
+
* 模式下非当前 home 没有本地 node_modules)。
|
|
34
|
+
*/
|
|
35
|
+
function readOwnVersion() {
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} catch (e) {
|
|
41
|
-
console.error(` version.json update failed (non-fatal): ${e.message}`);
|
|
37
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
38
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
42
41
|
}
|
|
42
|
+
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 检测当前 tmux session/socket。SEAM_CC_SESSION env 优先,否则回退
|
|
46
|
+
* `tmux display-message`。回退只对"当前 home"成立——AI 在自己的终端里
|
|
47
|
+
* 跑 upgrade,display-message 解析到的就是它自己。跨 home 的 caller
|
|
48
|
+
* (upgrade-all)不用这个,改从 registry entry 取 session/socket。
|
|
49
|
+
*/
|
|
50
|
+
function detectCurrentSession() {
|
|
51
|
+
const ccSocket = resolveTmuxSocketPath() || '';
|
|
52
|
+
const tmux = ccSocket ? `tmux -S "${ccSocket}"` : 'tmux';
|
|
53
|
+
let ccSession = process.env.SEAM_CC_SESSION || '';
|
|
54
|
+
if (!ccSession) {
|
|
48
55
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
ccSession = execSync(`${tmux} display-message -p '#S'`, { encoding: 'utf8' }).trim();
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
return { ccSession, ccSocket };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function patchSettings(settingsPath) {
|
|
63
|
+
if (!existsSync(settingsPath)) {
|
|
64
|
+
console.log(' no .claude/settings.json, skip');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
69
|
+
let changed = false;
|
|
70
|
+
const oldCmd = 'npx @seamnet/client autostart';
|
|
71
|
+
const newCmd = 'npx seam-client autostart';
|
|
72
|
+
for (const group of s.hooks?.SessionStart || []) {
|
|
73
|
+
for (const h of group.hooks || []) {
|
|
74
|
+
if (h.command === oldCmd) {
|
|
75
|
+
h.command = newCmd;
|
|
76
|
+
changed = true;
|
|
59
77
|
}
|
|
60
78
|
}
|
|
61
|
-
// 预授权 seam CLI:老 AI 升级后用 `seam` 发消息的路径立刻可用
|
|
62
|
-
if (!s.permissions) s.permissions = {};
|
|
63
|
-
if (!Array.isArray(s.permissions.allow)) s.permissions.allow = [];
|
|
64
|
-
if (!s.permissions.allow.includes('Bash(seam *)')) {
|
|
65
|
-
s.permissions.allow.push('Bash(seam *)');
|
|
66
|
-
changed = true;
|
|
67
|
-
}
|
|
68
|
-
if (changed) {
|
|
69
|
-
writeFileSync(settingsPath, JSON.stringify(s, null, 2));
|
|
70
|
-
console.log(' settings.json patched (hook command + seam CLI 预授权 Bash(seam *))');
|
|
71
|
-
} else {
|
|
72
|
-
console.log(' settings.json already up-to-date, skip');
|
|
73
|
-
}
|
|
74
|
-
} catch (e) {
|
|
75
|
-
console.error(` failed to patch settings.json: ${e.message}`);
|
|
76
79
|
}
|
|
80
|
+
// 预授权 seam CLI:老 AI 升级后用 `seam` 发消息的路径立刻可用
|
|
81
|
+
if (!s.permissions) s.permissions = {};
|
|
82
|
+
if (!Array.isArray(s.permissions.allow)) s.permissions.allow = [];
|
|
83
|
+
if (!s.permissions.allow.includes('Bash(seam *)')) {
|
|
84
|
+
s.permissions.allow.push('Bash(seam *)');
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
if (changed) {
|
|
88
|
+
writeFileSync(settingsPath, JSON.stringify(s, null, 2));
|
|
89
|
+
console.log(' settings.json patched (hook command + seam CLI 预授权 Bash(seam *))');
|
|
90
|
+
} else {
|
|
91
|
+
console.log(' settings.json already up-to-date, skip');
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(` failed to patch settings.json: ${e.message}`);
|
|
77
95
|
}
|
|
96
|
+
}
|
|
78
97
|
|
|
79
|
-
|
|
80
|
-
console.log('3. refreshing CLAUDE.md references + CHANNEL_RULES.md');
|
|
98
|
+
function cleanMcpJson(mcpPath) {
|
|
81
99
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
if (!existsSync(mcpPath)) {
|
|
101
|
+
console.log(' no .mcp.json, skip');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
105
|
+
if (mcp.mcpServers && Object.prototype.hasOwnProperty.call(mcp.mcpServers, 'seam-im')) {
|
|
106
|
+
delete mcp.mcpServers['seam-im'];
|
|
107
|
+
writeFileSync(mcpPath, JSON.stringify(mcp, null, 2));
|
|
108
|
+
console.log(' removed seam-im entry from .mcp.json');
|
|
109
|
+
} else {
|
|
110
|
+
console.log(' no seam-im entry, skip');
|
|
111
|
+
}
|
|
85
112
|
} catch (e) {
|
|
86
|
-
console.error(`
|
|
113
|
+
console.error(` .mcp.json cleanup failed (non-fatal): ${e.message}`);
|
|
87
114
|
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 对指定 home 做 per-home 升级。不含 npm install。
|
|
119
|
+
*
|
|
120
|
+
* guardian 重启一律 spawn 子进程(带 SEAM_HOME/SEAM_CC_SESSION/SEAM_CC_SOCKET
|
|
121
|
+
* env)——不在进程内调 guardianStop/Start,那俩认死的 SEAM_DIR/PID_PATH,
|
|
122
|
+
* 对非当前 home 会操作错对象。
|
|
123
|
+
*
|
|
124
|
+
* @param {object} o
|
|
125
|
+
* @param {string} o.seamHome 绝对路径,<project>/.seam
|
|
126
|
+
* @param {string} [o.ccSession] 该 home 的 tmux session(此处不回退 display-message)
|
|
127
|
+
* @param {string} [o.ccSocket] 该 home 的 tmux socket
|
|
128
|
+
*/
|
|
129
|
+
export async function applyHomeUpgrade({ seamHome, ccSession, ccSocket }) {
|
|
130
|
+
if (!seamHome) throw new Error('applyHomeUpgrade: seamHome required');
|
|
131
|
+
const projectDir = dirname(seamHome);
|
|
88
132
|
|
|
89
|
-
//
|
|
90
|
-
// mcp-serve 已不存在——必须在 CC restart 之前剥掉,否则重启会加载到死引用)
|
|
91
|
-
console.log('3b. cleaning seam-im from .mcp.json');
|
|
133
|
+
// version.json
|
|
92
134
|
try {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
} else {
|
|
101
|
-
console.log(' no seam-im entry, skip');
|
|
102
|
-
}
|
|
103
|
-
} else {
|
|
104
|
-
console.log(' no .mcp.json, skip');
|
|
135
|
+
const version = readOwnVersion();
|
|
136
|
+
if (version) {
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(seamHome, 'version.json'),
|
|
139
|
+
JSON.stringify({ version, upgradedAt: new Date().toISOString() }, null, 2)
|
|
140
|
+
);
|
|
141
|
+
console.log(` version.json → ${version}`);
|
|
105
142
|
}
|
|
106
143
|
} catch (e) {
|
|
107
|
-
console.error(`
|
|
144
|
+
console.error(` version.json update failed (non-fatal): ${e.message}`);
|
|
108
145
|
}
|
|
109
146
|
|
|
110
|
-
//
|
|
111
|
-
|
|
147
|
+
// patch .claude/settings.json
|
|
148
|
+
patchSettings(join(projectDir, '.claude', 'settings.json'));
|
|
149
|
+
|
|
150
|
+
// 刷新 CHANNEL_RULES.md + CLAUDE.md 引用(显式传 home/projectDir)
|
|
112
151
|
try {
|
|
113
|
-
const {
|
|
114
|
-
|
|
115
|
-
|
|
152
|
+
const { syncChannelRules, patchClaudeMd } = await import('./init.js');
|
|
153
|
+
syncChannelRules(seamHome);
|
|
154
|
+
patchClaudeMd(projectDir);
|
|
155
|
+
console.log(' CHANNEL_RULES + CLAUDE.md refreshed');
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(` CHANNEL_RULES/CLAUDE.md refresh failed (non-fatal): ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 剥 .mcp.json 的 seam-im(CC restart 前剥掉指向已删 mcp-serve 的死引用)
|
|
161
|
+
cleanMcpJson(join(projectDir, '.mcp.json'));
|
|
116
162
|
|
|
117
|
-
|
|
118
|
-
|
|
163
|
+
// 重启 guardian:spawn 子进程 stop → 置 flag → start
|
|
164
|
+
const env = {
|
|
165
|
+
...process.env,
|
|
166
|
+
SEAM_HOME: seamHome,
|
|
167
|
+
SEAM_CC_SESSION: ccSession || '',
|
|
168
|
+
SEAM_CC_SOCKET: ccSocket || '',
|
|
169
|
+
};
|
|
170
|
+
const stopRes = spawnSync(process.execPath, [CLI_PATH, 'stop'], { env, stdio: 'inherit' });
|
|
171
|
+
if (stopRes.status !== 0) {
|
|
172
|
+
// stop 非 0 不致命——guardian 可能本来就没在跑
|
|
173
|
+
console.log(` guardian stop exited ${stopRes.status}(可能本来没跑,继续)`);
|
|
174
|
+
}
|
|
175
|
+
await sleep(1000);
|
|
176
|
+
|
|
177
|
+
// 置 pending_upgrade_restart——新 guardian 启动时读到会自动重启 CC
|
|
178
|
+
try {
|
|
179
|
+
const statePath = join(seamHome, 'state.json');
|
|
119
180
|
let stateJson = {};
|
|
120
181
|
if (existsSync(statePath)) {
|
|
121
182
|
try { stateJson = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
|
|
@@ -123,10 +184,36 @@ export async function upgrade() {
|
|
|
123
184
|
if (!stateJson.guardian) stateJson.guardian = {};
|
|
124
185
|
stateJson.guardian.pending_upgrade_restart = true;
|
|
125
186
|
writeFileSync(statePath, JSON.stringify(stateJson, null, 2));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(` state.json flag failed (non-fatal): ${e.message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const startRes = spawnSync(process.execPath, [CLI_PATH, 'guardian', 'start'], { env, stdio: 'inherit' });
|
|
192
|
+
if (startRes.status !== 0) {
|
|
193
|
+
throw new Error(`guardian start failed (exit ${startRes.status})`);
|
|
194
|
+
}
|
|
195
|
+
console.log(' guardian restarted');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function upgrade() {
|
|
199
|
+
console.log('Seam — upgrading...\n');
|
|
200
|
+
|
|
201
|
+
// 1. 本地升包
|
|
202
|
+
console.log('1. npm install @seamnet/client@latest');
|
|
203
|
+
try {
|
|
204
|
+
execSync('npm install @seamnet/client@latest', { stdio: 'inherit' });
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error(` npm install failed: ${e.message}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
126
209
|
|
|
127
|
-
|
|
210
|
+
// 2. 对当前 home 跑 applyHomeUpgrade(session 走 display-message 检测)
|
|
211
|
+
console.log('2. applying per-home upgrade');
|
|
212
|
+
const { ccSession, ccSocket } = detectCurrentSession();
|
|
213
|
+
try {
|
|
214
|
+
await applyHomeUpgrade({ seamHome: SEAM_DIR, ccSession, ccSocket });
|
|
128
215
|
} catch (e) {
|
|
129
|
-
console.error(`
|
|
216
|
+
console.error(` upgrade failed: ${e.message}`);
|
|
130
217
|
console.log('\n 请手动 /exit 并重新启动 Claude Code。');
|
|
131
218
|
return;
|
|
132
219
|
}
|