@seamnet/client 0.19.0 → 0.20.1
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 +81 -25
- package/lib/guardian.js +5 -4
- package/lib/tell.js +3 -3
- package/lib/tmux-utils.cjs +240 -90
- 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,58 @@ 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
|
+
let registryReadError = null;
|
|
503
|
+
try {
|
|
504
|
+
const { readAll } = await import('../lib/registry.js');
|
|
505
|
+
guardianEntry = readAll().find((e) => e.tmux_session === values.session) || null;
|
|
506
|
+
} catch (e) {
|
|
507
|
+
// registry 读失败不静默——stop 杀了 guardian 不能悄无声息
|
|
508
|
+
registryReadError = e.message;
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const { kind } = tmuxUtils.stopTerm(values.session);
|
|
512
|
+
const result = { session: values.session, kind, stopped: true };
|
|
513
|
+
if (guardianEntry) {
|
|
514
|
+
result.guardian_killed = true;
|
|
515
|
+
result.warning = `注意:已一并终止该 home 的 guardian(userId=${guardianEntry.userId}, home=${guardianEntry.seam_home})`;
|
|
516
|
+
} else if (registryReadError) {
|
|
517
|
+
result.warning = `registry 读取失败(${registryReadError})——无法确认该会话是否绑 guardian;`
|
|
518
|
+
+ `若这是 cc 会话(kind=${kind}),guardian 可能已随会话一并终止`;
|
|
519
|
+
} else if (kind === 'cc') {
|
|
520
|
+
result.warning = '注意:这是个 cc 会话——若它绑着 guardian,guardian 已随会话一并终止';
|
|
521
|
+
}
|
|
522
|
+
output(true, result);
|
|
469
523
|
} catch (e) {
|
|
470
524
|
output(false, e.message);
|
|
471
525
|
}
|
|
@@ -485,11 +539,13 @@ async function cmdCc(subAction, restArgs) {
|
|
|
485
539
|
if (!values.session) output(false, '--session required');
|
|
486
540
|
const continueSession = values['no-continue'] ? false : true;
|
|
487
541
|
try {
|
|
488
|
-
const
|
|
489
|
-
if (ok) {
|
|
490
|
-
|
|
542
|
+
const r = await tmuxUtils.restartTerm(values.session, { continueSession });
|
|
543
|
+
if (r.ok) {
|
|
544
|
+
const result = { session: values.session, restarted: true, startCmd: r.startCmd };
|
|
545
|
+
if (r.warning) result.warning = r.warning;
|
|
546
|
+
output(true, result);
|
|
491
547
|
} else {
|
|
492
|
-
output(false, 'restart failed:
|
|
548
|
+
output(false, r.warning || 'restart failed: 终端未在超时内退出或重启');
|
|
493
549
|
}
|
|
494
550
|
} catch (e) {
|
|
495
551
|
output(false, e.message);
|
|
@@ -497,7 +553,7 @@ async function cmdCc(subAction, restArgs) {
|
|
|
497
553
|
return;
|
|
498
554
|
}
|
|
499
555
|
|
|
500
|
-
output(false, `Unknown
|
|
556
|
+
output(false, `Unknown term action: ${subAction}. Try: list, read, send, start, stop, restart`);
|
|
501
557
|
}
|
|
502
558
|
|
|
503
559
|
function parseDuration(str) {
|
|
@@ -529,7 +585,7 @@ function parseDuration(str) {
|
|
|
529
585
|
if (domain === 'inbox') return await cmdInbox(action, rest);
|
|
530
586
|
if (domain === 'self') return await cmdSelf(action, rest);
|
|
531
587
|
if (domain === 'invite') return await cmdInvite(action, rest);
|
|
532
|
-
if (domain === '
|
|
588
|
+
if (domain === 'term') return await cmdTerm(action, rest);
|
|
533
589
|
output(false, `Unknown domain: ${domain}. Try: seam --help`);
|
|
534
590
|
} catch (e) {
|
|
535
591
|
output(false, e.message);
|
package/lib/guardian.js
CHANGED
|
@@ -252,14 +252,15 @@ export async function guardianRun() {
|
|
|
252
252
|
hub.inject(notice);
|
|
253
253
|
setTimeout(async () => {
|
|
254
254
|
try {
|
|
255
|
-
const {
|
|
256
|
-
const
|
|
257
|
-
if (ok) {
|
|
255
|
+
const { restartTerm } = require('./tmux-utils.cjs');
|
|
256
|
+
const r = await restartTerm(safeSession, { socketPath: ccSocket, continueSession: true });
|
|
257
|
+
if (r.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
|
+
if (r.warning) hub.logger('guardian').warn('cc_restart_warning', { warning: r.warning });
|
|
261
262
|
} else {
|
|
262
|
-
hub.logger('guardian').error('cc_restart_failed', new Error('
|
|
263
|
+
hub.logger('guardian').error('cc_restart_failed', new Error(r.warning || 'restartTerm returned ok:false'));
|
|
263
264
|
}
|
|
264
265
|
} catch (e) {
|
|
265
266
|
hub.logger('guardian').error('cc_restart_failed', e);
|
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,307 @@
|
|
|
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 与 codex 都走对称的优雅路径,差别全在表里。
|
|
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 → 启动命令族。声明式数据小表,不是 adapter。新增 kind 加一行即可。
|
|
19
|
+
// - fresh:全新会话的启动命令
|
|
20
|
+
// - resume:续上一次会话的命令(restart 默认走这个;--no-continue 走 fresh)
|
|
21
|
+
// - exit:TUI 内的优雅退出命令(restart 发它 + Enter)
|
|
22
|
+
// - downTimeout:发了 exit 后等进程退出的轮询超时(ms)
|
|
23
|
+
const KIND_CMD = {
|
|
24
|
+
cc: {
|
|
25
|
+
fresh: 'claude --dangerously-skip-permissions',
|
|
26
|
+
resume: 'claude --dangerously-skip-permissions --continue',
|
|
27
|
+
exit: '/exit',
|
|
28
|
+
downTimeout: 120000, // cc 退出跑 Stop hook(auto-save / semantic_memory)要 ~90s
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
fresh: 'codex -m gpt-5.4',
|
|
32
|
+
resume: 'codex resume --last',
|
|
33
|
+
exit: '/exit',
|
|
34
|
+
downTimeout: 60000, // 实测 codex /exit ~25s(退出存 session),留余量
|
|
35
|
+
},
|
|
36
|
+
shell: {
|
|
37
|
+
fresh: 'bash',
|
|
38
|
+
// shell 无优雅 restart——restartTerm 对 shell 直接报错
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const KIND_PREFIX = { cc: 'cc', codex: 'codex', shell: 'sh' };
|
|
42
|
+
|
|
16
43
|
function tmuxCmd(socketPath) {
|
|
17
44
|
return socketPath ? `tmux -S "${socketPath}"` : 'tmux';
|
|
18
45
|
}
|
|
19
46
|
|
|
20
|
-
function
|
|
47
|
+
function sleep(ms) {
|
|
48
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 检测 session 里跑的是什么:'cc' | 'codex' | 'shell'。session 不存在返回 null。
|
|
53
|
+
*
|
|
54
|
+
* 从 pane_pid BFS 进程子树,遇到第一个 comm 含 claude/codex 的进程即返回——
|
|
55
|
+
* 「最浅命中」。npm 装的 codex 是 node 包装脚本(comm 看到的是 node/MainThread),
|
|
56
|
+
* 真 codex 是更深一层的 Rust 二进制(comm=codex),所以必须穿透子树。
|
|
57
|
+
*
|
|
58
|
+
* 为什么是「最浅命中」而非「收集全部」:cc 会话可能 spawn codex 子进程
|
|
59
|
+
* (比如 AI 用 codex skill)——那个 codex 在很深的层。最浅命中保证最外层的
|
|
60
|
+
* 那个有意义进程(panePid 处或附近的 claude)决定 kind,不被深层 codex 干扰。
|
|
61
|
+
*/
|
|
62
|
+
function detectKind(session, socketPath) {
|
|
21
63
|
const tmux = tmuxCmd(socketPath);
|
|
22
|
-
let
|
|
64
|
+
let panePid;
|
|
23
65
|
try {
|
|
24
|
-
|
|
25
|
-
.trim().split('\n').filter(Boolean);
|
|
66
|
+
panePid = execSync(`${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS).trim();
|
|
26
67
|
} catch {
|
|
27
|
-
return
|
|
68
|
+
return null;
|
|
28
69
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
70
|
+
let table;
|
|
71
|
+
try {
|
|
72
|
+
table = execSync('ps -e -o pid=,ppid=,comm=', { ...EXEC_OPTS, maxBuffer: 4 * 1024 * 1024 });
|
|
73
|
+
} catch {
|
|
74
|
+
return 'shell';
|
|
75
|
+
}
|
|
76
|
+
const childrenOf = new Map();
|
|
77
|
+
const commOf = new Map();
|
|
78
|
+
for (const line of table.split('\n')) {
|
|
79
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
80
|
+
if (!m) continue;
|
|
81
|
+
const [, pid, ppid, comm] = m;
|
|
82
|
+
commOf.set(pid, comm);
|
|
83
|
+
if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
|
|
84
|
+
childrenOf.get(ppid).push(pid);
|
|
85
|
+
}
|
|
86
|
+
// BFS:最浅命中即返回
|
|
87
|
+
const queue = [String(panePid)];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
let guard = 0;
|
|
90
|
+
while (queue.length && guard++ < 5000) {
|
|
91
|
+
const pid = queue.shift();
|
|
92
|
+
if (seen.has(pid)) continue;
|
|
93
|
+
seen.add(pid);
|
|
94
|
+
const comm = (commOf.get(pid) || '').toLowerCase();
|
|
95
|
+
if (comm.includes('claude')) return 'cc';
|
|
96
|
+
if (comm.includes('codex')) return 'codex';
|
|
97
|
+
for (const c of childrenOf.get(pid) || []) queue.push(c);
|
|
98
|
+
}
|
|
99
|
+
return 'shell';
|
|
33
100
|
}
|
|
34
101
|
|
|
35
|
-
|
|
102
|
+
/**
|
|
103
|
+
* 列出所有 tmux 会话 + 每个的 kind。
|
|
104
|
+
*/
|
|
105
|
+
function listTerms(socketPath) {
|
|
36
106
|
const tmux = tmuxCmd(socketPath);
|
|
107
|
+
let lines;
|
|
37
108
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
109
|
+
lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
|
|
110
|
+
.trim().split('\n').filter(Boolean);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return lines.map((name) => ({ session: name, kind: detectKind(name, socketPath) }));
|
|
50
115
|
}
|
|
51
116
|
|
|
52
117
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
118
|
+
* 读 session 屏幕。默认 last N 行;full=true 读整屏 + scrollback 历史。
|
|
119
|
+
*
|
|
120
|
+
* 一律 `capture-pane -p -S -` 抓全量,在 JS 里去掉尾部空行再切最后 N 行。
|
|
121
|
+
* 不用 shell `| tail`——capture-pane 会把光标下方的空 viewport 行也输出,
|
|
122
|
+
* shell 的 tail 先抓到空行,内容稀疏的会话就读不到东西。
|
|
55
123
|
*/
|
|
56
|
-
function
|
|
124
|
+
function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
|
|
57
125
|
const tmux = tmuxCmd(socketPath);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
126
|
+
const raw = execSync(
|
|
127
|
+
`${tmux} capture-pane -t "${session}" -p -S -`,
|
|
128
|
+
{ ...EXEC_OPTS, maxBuffer: 16 * 1024 * 1024 }
|
|
129
|
+
);
|
|
130
|
+
if (full) return raw;
|
|
131
|
+
const n = parseInt(lines, 10) || 20;
|
|
132
|
+
return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
|
|
65
133
|
}
|
|
66
134
|
|
|
67
135
|
/**
|
|
68
|
-
*
|
|
136
|
+
* 给 session 发文本和/或命名按键。
|
|
137
|
+
* - text:字面文本(-l)。默认末尾自动 Enter;noEnter 或同时有 keys 时抑制自动 Enter。
|
|
138
|
+
* - keys:tmux 命名按键(C-c / Enter / Escape / Up / Tab ...),按顺序发,不加 Enter。
|
|
139
|
+
* 二者可叠加:text 先发,再按顺序发 keys。
|
|
69
140
|
*/
|
|
70
|
-
function
|
|
141
|
+
function sendTerm(session, { text, noEnter = false, keys = [], socketPath } = {}) {
|
|
71
142
|
const tmux = tmuxCmd(socketPath);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
143
|
+
const hasText = text != null && text !== '';
|
|
144
|
+
const hasKeys = Array.isArray(keys) && keys.length > 0;
|
|
145
|
+
if (!hasText && !hasKeys) {
|
|
146
|
+
throw new Error('sendTerm: 需要 text 或 keys 至少一个');
|
|
147
|
+
}
|
|
148
|
+
if (hasText) {
|
|
149
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
|
|
150
|
+
if (!noEnter && !hasKeys) {
|
|
151
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const k of (hasKeys ? keys : [])) {
|
|
155
|
+
execSync(`${tmux} send-keys -t "${session}" ${JSON.stringify(k)}`, EXEC_OPTS);
|
|
156
|
+
}
|
|
76
157
|
}
|
|
77
158
|
|
|
78
159
|
/**
|
|
79
|
-
*
|
|
160
|
+
* 新建 tmux 会话并在里面起东西。会话名带 kind 前缀(cc-/codex-/sh-)。
|
|
161
|
+
* 会话先跑 bash,再 send 启动命令进去——这样程序退出后 bash 还在、
|
|
162
|
+
* 会话不死,restart 能在原会话里重起。
|
|
80
163
|
*/
|
|
81
|
-
function
|
|
164
|
+
function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
|
|
82
165
|
const tmux = tmuxCmd(socketPath);
|
|
83
|
-
if (!
|
|
166
|
+
if (!kind || !(kind in KIND_CMD)) {
|
|
167
|
+
throw new Error(`startTerm: --kind 必须是 ${Object.keys(KIND_CMD).join('|')}`);
|
|
168
|
+
}
|
|
169
|
+
if (dir && !existsSync(dir)) throw new Error(`dir not found: ${dir}`);
|
|
170
|
+
const base = session || (dir ? basename(dir) : null);
|
|
171
|
+
if (!base) throw new Error('startTerm: 需要 --session 或 --dir');
|
|
172
|
+
const sessionName = `${KIND_PREFIX[kind]}-${base}`;
|
|
84
173
|
try {
|
|
85
|
-
execSync(`${tmux} has-session -t "${
|
|
86
|
-
throw new Error(`session "${
|
|
174
|
+
execSync(`${tmux} has-session -t "${sessionName}" 2>/dev/null`, EXEC_OPTS);
|
|
175
|
+
throw new Error(`session "${sessionName}" already exists`);
|
|
87
176
|
} catch (e) {
|
|
88
177
|
if (e.message.includes('already exists')) throw e;
|
|
89
178
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
179
|
+
const cwdArg = dir ? `-c "${dir}"` : '';
|
|
180
|
+
execSync(`${tmux} new-session -d -s "${sessionName}" ${cwdArg}`.trim(), { ...EXEC_OPTS, timeout: 10000 });
|
|
181
|
+
// kind=shell 且无 --cmd:new-session 本身就是 bash,不必再 send。
|
|
182
|
+
const startCmd = cmd || KIND_CMD[kind].fresh;
|
|
183
|
+
if (cmd || kind !== 'shell') {
|
|
184
|
+
execSync(`${tmux} send-keys -t "${sessionName}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
185
|
+
execSync(`${tmux} send-keys -t "${sessionName}" Enter`, EXEC_OPTS);
|
|
186
|
+
}
|
|
187
|
+
return { session: sessionName, kind, cmd: startCmd };
|
|
98
188
|
}
|
|
99
189
|
|
|
100
190
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
191
|
+
* restart 通用骨架:停 → 轮询确认停了 → 起 → 轮询确认起了 → 5s 存活复检。
|
|
192
|
+
* 返回 { ok, warning }。
|
|
103
193
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
194
|
+
* 三个兜底(0.20.1 加):
|
|
195
|
+
* - poll-down 超时:强制 kill(C-c → 不行再 kill pane 子进程)+ warning,然后照常 relaunch
|
|
196
|
+
* - poll-up 后 ~5s 存活复检:化解「起来一瞬间被检测到、随即自更新退出」的假 success
|
|
197
|
+
* - poll-up 失败:C-c 清半行重试一次
|
|
107
198
|
*/
|
|
108
|
-
async function
|
|
199
|
+
async function restartSkeleton(session, socketPath, { targetKind, stopAction, downTimeout, startCmd }) {
|
|
109
200
|
const tmux = tmuxCmd(socketPath);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
let warning = null;
|
|
202
|
+
|
|
203
|
+
// 1. 停
|
|
204
|
+
stopAction();
|
|
205
|
+
|
|
206
|
+
// 2. poll-down:轮询直到 kind 不再是 targetKind;超时强制 kill 兜底
|
|
207
|
+
const downDeadline = Date.now() + downTimeout;
|
|
208
|
+
while (detectKind(session, socketPath) === targetKind) {
|
|
209
|
+
if (Date.now() >= downDeadline) {
|
|
210
|
+
warning = `${targetKind} 未在 ${Math.round(downTimeout / 1000)}s 内优雅退出,已强制终止;`
|
|
211
|
+
+ (targetKind === 'codex' ? 'resume 的会话可能不完整' : '会话状态可能不完整');
|
|
212
|
+
try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
|
|
213
|
+
await sleep(2000);
|
|
214
|
+
if (detectKind(session, socketPath) === targetKind) {
|
|
215
|
+
try {
|
|
216
|
+
const panePid = execSync(
|
|
217
|
+
`${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS
|
|
218
|
+
).trim();
|
|
219
|
+
execSync(`pkill -KILL -P ${panePid} 2>/dev/null || true`, EXEC_OPTS);
|
|
220
|
+
} catch {}
|
|
221
|
+
await sleep(2000);
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
124
224
|
}
|
|
125
225
|
await sleep(1000);
|
|
126
226
|
}
|
|
127
227
|
|
|
128
|
-
// 3.
|
|
228
|
+
// 3. 等 shell prompt 落定
|
|
129
229
|
await sleep(500);
|
|
130
230
|
|
|
131
|
-
// 4-5.
|
|
231
|
+
// 4-5. 起 + poll-up(30s)+ 5s 存活复检
|
|
132
232
|
const tryStart = async () => {
|
|
133
233
|
try {
|
|
134
|
-
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(
|
|
234
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
135
235
|
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
136
236
|
} catch {
|
|
137
237
|
return false;
|
|
138
238
|
}
|
|
139
239
|
const upDeadline = Date.now() + 30000;
|
|
140
|
-
while (
|
|
240
|
+
while (detectKind(session, socketPath) !== targetKind) {
|
|
141
241
|
if (Date.now() >= upDeadline) return false;
|
|
142
242
|
await sleep(1000);
|
|
143
243
|
}
|
|
144
|
-
|
|
244
|
+
// 存活复检:起来后等 5s 再确认还在(codex 启动可能撞自更新菜单、随即自退)
|
|
245
|
+
await sleep(5000);
|
|
246
|
+
return detectKind(session, socketPath) === targetKind;
|
|
145
247
|
};
|
|
146
248
|
|
|
147
|
-
if (await tryStart()) return true;
|
|
249
|
+
if (await tryStart()) return { ok: true, warning, startCmd };
|
|
148
250
|
|
|
149
|
-
// 6.
|
|
150
|
-
try {
|
|
151
|
-
execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS);
|
|
152
|
-
} catch {}
|
|
251
|
+
// 6. 没起来 / 起来又随即退:C-c 清半行后重试一次
|
|
252
|
+
try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
|
|
153
253
|
await sleep(500);
|
|
154
|
-
|
|
254
|
+
if (await tryStart()) return { ok: true, warning, startCmd };
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
startCmd,
|
|
259
|
+
warning: warning
|
|
260
|
+
|| `${targetKind} 启动后未稳定存活(可能启动时撞自更新随即退出),请 seam term read 确认`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 重启 session 里跑的东西。只对 cc / codex 有意义——非 cc/codex(shell 或
|
|
266
|
+
* 自定义命令)直接报错,不静默重启成 bash。
|
|
267
|
+
*
|
|
268
|
+
* cc 与 codex 都走对称的优雅路径(发 exit 命令 → 轮询退出 → 续命命令重起),
|
|
269
|
+
* 差别(退出命令 / 超时 / 续命命令)全在 KIND_CMD 表里。
|
|
270
|
+
* 返回 { ok, warning }。
|
|
271
|
+
*/
|
|
272
|
+
async function restartTerm(session, { socketPath, continueSession = true } = {}) {
|
|
273
|
+
const tmux = tmuxCmd(socketPath);
|
|
274
|
+
const kind = detectKind(session, socketPath);
|
|
275
|
+
if (kind !== 'cc' && kind !== 'codex') {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`"${session}" 不是 cc/codex 会话(检测到 kind=${kind === null ? '会话不存在' : kind}),`
|
|
278
|
+
+ 'restart 不知道该用什么命令重启它。要重起请用 `seam term start --kind ... --cmd "..."`。'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const spec = KIND_CMD[kind];
|
|
282
|
+
const startCmd = continueSession ? spec.resume : spec.fresh;
|
|
283
|
+
return restartSkeleton(session, socketPath, {
|
|
284
|
+
targetKind: kind,
|
|
285
|
+
stopAction: () => {
|
|
286
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(spec.exit)}`, EXEC_OPTS);
|
|
287
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
288
|
+
},
|
|
289
|
+
downTimeout: spec.downTimeout,
|
|
290
|
+
startCmd,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 关掉整个终端(kill-session)。返回关之前检测到的 kind。
|
|
296
|
+
*/
|
|
297
|
+
function stopTerm(session, { socketPath } = {}) {
|
|
298
|
+
const tmux = tmuxCmd(socketPath);
|
|
299
|
+
const kind = detectKind(session, socketPath);
|
|
300
|
+
execSync(`${tmux} kill-session -t "${session}"`, EXEC_OPTS);
|
|
301
|
+
return { kind };
|
|
155
302
|
}
|
|
156
303
|
|
|
157
|
-
module.exports = {
|
|
304
|
+
module.exports = {
|
|
305
|
+
tmuxCmd, detectKind, listTerms, readTerm, sendTerm,
|
|
306
|
+
startTerm, restartTerm, stopTerm, KIND_CMD,
|
|
307
|
+
};
|