@seamnet/client 0.20.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 +14 -5
- package/lib/guardian.js +4 -3
- package/lib/tmux-utils.cjs +121 -59
- package/package.json +1 -1
package/bin/seam.js
CHANGED
|
@@ -499,16 +499,23 @@ async function cmdTerm(subAction, restArgs) {
|
|
|
499
499
|
if (!values.session) output(false, '--session required');
|
|
500
500
|
// 关之前查 registry:这个 session 是否绑着一个 guardian
|
|
501
501
|
let guardianEntry = null;
|
|
502
|
+
let registryReadError = null;
|
|
502
503
|
try {
|
|
503
504
|
const { readAll } = await import('../lib/registry.js');
|
|
504
505
|
guardianEntry = readAll().find((e) => e.tmux_session === values.session) || null;
|
|
505
|
-
} catch {
|
|
506
|
+
} catch (e) {
|
|
507
|
+
// registry 读失败不静默——stop 杀了 guardian 不能悄无声息
|
|
508
|
+
registryReadError = e.message;
|
|
509
|
+
}
|
|
506
510
|
try {
|
|
507
511
|
const { kind } = tmuxUtils.stopTerm(values.session);
|
|
508
512
|
const result = { session: values.session, kind, stopped: true };
|
|
509
513
|
if (guardianEntry) {
|
|
510
514
|
result.guardian_killed = true;
|
|
511
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 可能已随会话一并终止`;
|
|
512
519
|
} else if (kind === 'cc') {
|
|
513
520
|
result.warning = '注意:这是个 cc 会话——若它绑着 guardian,guardian 已随会话一并终止';
|
|
514
521
|
}
|
|
@@ -532,11 +539,13 @@ async function cmdTerm(subAction, restArgs) {
|
|
|
532
539
|
if (!values.session) output(false, '--session required');
|
|
533
540
|
const continueSession = values['no-continue'] ? false : true;
|
|
534
541
|
try {
|
|
535
|
-
const
|
|
536
|
-
if (ok) {
|
|
537
|
-
|
|
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);
|
|
538
547
|
} else {
|
|
539
|
-
output(false, 'restart failed: 终端未在超时内退出或重启');
|
|
548
|
+
output(false, r.warning || 'restart failed: 终端未在超时内退出或重启');
|
|
540
549
|
}
|
|
541
550
|
} catch (e) {
|
|
542
551
|
output(false, e.message);
|
package/lib/guardian.js
CHANGED
|
@@ -253,13 +253,14 @@ export async function guardianRun() {
|
|
|
253
253
|
setTimeout(async () => {
|
|
254
254
|
try {
|
|
255
255
|
const { restartTerm } = require('./tmux-utils.cjs');
|
|
256
|
-
const
|
|
257
|
-
if (ok) {
|
|
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('restartTerm returned false'));
|
|
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/tmux-utils.cjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 这层与 pane 里跑什么无关。交互语义(审批流、idle 判断等)故意不抽象——
|
|
6
6
|
* 由操作者读屏自己决定按什么键。
|
|
7
7
|
*
|
|
8
|
-
* 唯一的 per-kind 知识:KIND_CMD 表(kind →
|
|
9
|
-
* cc
|
|
8
|
+
* 唯一的 per-kind 知识:KIND_CMD 表(kind → 启动/续命/退出/超时)。restart
|
|
9
|
+
* 时 cc 与 codex 都走对称的优雅路径,差别全在表里。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const { execSync } = require('node:child_process');
|
|
@@ -15,11 +15,28 @@ const { basename } = require('node:path');
|
|
|
15
15
|
|
|
16
16
|
const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
17
17
|
|
|
18
|
-
// kind →
|
|
18
|
+
// kind → 启动命令族。声明式数据小表,不是 adapter。新增 kind 加一行即可。
|
|
19
|
+
// - fresh:全新会话的启动命令
|
|
20
|
+
// - resume:续上一次会话的命令(restart 默认走这个;--no-continue 走 fresh)
|
|
21
|
+
// - exit:TUI 内的优雅退出命令(restart 发它 + Enter)
|
|
22
|
+
// - downTimeout:发了 exit 后等进程退出的轮询超时(ms)
|
|
19
23
|
const KIND_CMD = {
|
|
20
|
-
cc:
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
},
|
|
23
40
|
};
|
|
24
41
|
const KIND_PREFIX = { cc: 'cc', codex: 'codex', shell: 'sh' };
|
|
25
42
|
|
|
@@ -33,7 +50,14 @@ function sleep(ms) {
|
|
|
33
50
|
|
|
34
51
|
/**
|
|
35
52
|
* 检测 session 里跑的是什么:'cc' | 'codex' | 'shell'。session 不存在返回 null。
|
|
36
|
-
*
|
|
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 干扰。
|
|
37
61
|
*/
|
|
38
62
|
function detectKind(session, socketPath) {
|
|
39
63
|
const tmux = tmuxCmd(socketPath);
|
|
@@ -43,17 +67,35 @@ function detectKind(session, socketPath) {
|
|
|
43
67
|
} catch {
|
|
44
68
|
return null;
|
|
45
69
|
}
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
comms.push(execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, EXEC_OPTS).trim());
|
|
49
|
-
} catch {}
|
|
70
|
+
let table;
|
|
50
71
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
99
|
return 'shell';
|
|
58
100
|
}
|
|
59
101
|
|
|
@@ -75,10 +117,9 @@ function listTerms(socketPath) {
|
|
|
75
117
|
/**
|
|
76
118
|
* 读 session 屏幕。默认 last N 行;full=true 读整屏 + scrollback 历史。
|
|
77
119
|
*
|
|
78
|
-
*
|
|
120
|
+
* 一律 `capture-pane -p -S -` 抓全量,在 JS 里去掉尾部空行再切最后 N 行。
|
|
79
121
|
* 不用 shell `| tail`——capture-pane 会把光标下方的空 viewport 行也输出,
|
|
80
|
-
* shell 的 tail
|
|
81
|
-
* capture-pane 受 tmux history-limit 限制(默认 2000 行),输出有界。
|
|
122
|
+
* shell 的 tail 先抓到空行,内容稀疏的会话就读不到东西。
|
|
82
123
|
*/
|
|
83
124
|
function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
|
|
84
125
|
const tmux = tmuxCmd(socketPath);
|
|
@@ -106,7 +147,6 @@ function sendTerm(session, { text, noEnter = false, keys = [], socketPath } = {}
|
|
|
106
147
|
}
|
|
107
148
|
if (hasText) {
|
|
108
149
|
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
|
|
109
|
-
// 自动 Enter:仅 text-only 且未抑制时。混发按键 = 手动模式,不惊喜回车。
|
|
110
150
|
if (!noEnter && !hasKeys) {
|
|
111
151
|
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
112
152
|
}
|
|
@@ -139,7 +179,7 @@ function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
|
|
|
139
179
|
const cwdArg = dir ? `-c "${dir}"` : '';
|
|
140
180
|
execSync(`${tmux} new-session -d -s "${sessionName}" ${cwdArg}`.trim(), { ...EXEC_OPTS, timeout: 10000 });
|
|
141
181
|
// kind=shell 且无 --cmd:new-session 本身就是 bash,不必再 send。
|
|
142
|
-
const startCmd = cmd || KIND_CMD[kind];
|
|
182
|
+
const startCmd = cmd || KIND_CMD[kind].fresh;
|
|
143
183
|
if (cmd || kind !== 'shell') {
|
|
144
184
|
execSync(`${tmux} send-keys -t "${sessionName}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
145
185
|
execSync(`${tmux} send-keys -t "${sessionName}" Enter`, EXEC_OPTS);
|
|
@@ -148,20 +188,47 @@ function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
|
|
|
148
188
|
}
|
|
149
189
|
|
|
150
190
|
/**
|
|
151
|
-
* restart 通用骨架:停 → 轮询确认停了 → 起 → 轮询确认起了 →
|
|
152
|
-
*
|
|
191
|
+
* restart 通用骨架:停 → 轮询确认停了 → 起 → 轮询确认起了 → 5s 存活复检。
|
|
192
|
+
* 返回 { ok, warning }。
|
|
193
|
+
*
|
|
194
|
+
* 三个兜底(0.20.1 加):
|
|
195
|
+
* - poll-down 超时:强制 kill(C-c → 不行再 kill pane 子进程)+ warning,然后照常 relaunch
|
|
196
|
+
* - poll-up 后 ~5s 存活复检:化解「起来一瞬间被检测到、随即自更新退出」的假 success
|
|
197
|
+
* - poll-up 失败:C-c 清半行重试一次
|
|
153
198
|
*/
|
|
154
199
|
async function restartSkeleton(session, socketPath, { targetKind, stopAction, downTimeout, startCmd }) {
|
|
155
200
|
const tmux = tmuxCmd(socketPath);
|
|
201
|
+
let warning = null;
|
|
202
|
+
|
|
203
|
+
// 1. 停
|
|
156
204
|
stopAction();
|
|
157
|
-
|
|
205
|
+
|
|
206
|
+
// 2. poll-down:轮询直到 kind 不再是 targetKind;超时强制 kill 兜底
|
|
158
207
|
const downDeadline = Date.now() + downTimeout;
|
|
159
208
|
while (detectKind(session, socketPath) === targetKind) {
|
|
160
|
-
if (Date.now() >= downDeadline)
|
|
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;
|
|
224
|
+
}
|
|
161
225
|
await sleep(1000);
|
|
162
226
|
}
|
|
163
|
-
|
|
164
|
-
//
|
|
227
|
+
|
|
228
|
+
// 3. 等 shell prompt 落定
|
|
229
|
+
await sleep(500);
|
|
230
|
+
|
|
231
|
+
// 4-5. 起 + poll-up(30s)+ 5s 存活复检
|
|
165
232
|
const tryStart = async () => {
|
|
166
233
|
try {
|
|
167
234
|
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
|
|
@@ -174,21 +241,33 @@ async function restartSkeleton(session, socketPath, { targetKind, stopAction, do
|
|
|
174
241
|
if (Date.now() >= upDeadline) return false;
|
|
175
242
|
await sleep(1000);
|
|
176
243
|
}
|
|
177
|
-
|
|
244
|
+
// 存活复检:起来后等 5s 再确认还在(codex 启动可能撞自更新菜单、随即自退)
|
|
245
|
+
await sleep(5000);
|
|
246
|
+
return detectKind(session, socketPath) === targetKind;
|
|
178
247
|
};
|
|
179
|
-
|
|
180
|
-
|
|
248
|
+
|
|
249
|
+
if (await tryStart()) return { ok: true, warning, startCmd };
|
|
250
|
+
|
|
251
|
+
// 6. 没起来 / 起来又随即退:C-c 清半行后重试一次
|
|
181
252
|
try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
|
|
182
253
|
await sleep(500);
|
|
183
|
-
|
|
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
|
+
};
|
|
184
262
|
}
|
|
185
263
|
|
|
186
264
|
/**
|
|
187
265
|
* 重启 session 里跑的东西。只对 cc / codex 有意义——非 cc/codex(shell 或
|
|
188
266
|
* 自定义命令)直接报错,不静默重启成 bash。
|
|
189
267
|
*
|
|
190
|
-
* cc
|
|
191
|
-
*
|
|
268
|
+
* cc 与 codex 都走对称的优雅路径(发 exit 命令 → 轮询退出 → 续命命令重起),
|
|
269
|
+
* 差别(退出命令 / 超时 / 续命命令)全在 KIND_CMD 表里。
|
|
270
|
+
* 返回 { ok, warning }。
|
|
192
271
|
*/
|
|
193
272
|
async function restartTerm(session, { socketPath, continueSession = true } = {}) {
|
|
194
273
|
const tmux = tmuxCmd(socketPath);
|
|
@@ -199,38 +278,21 @@ async function restartTerm(session, { socketPath, continueSession = true } = {})
|
|
|
199
278
|
+ 'restart 不知道该用什么命令重启它。要重起请用 `seam term start --kind ... --cmd "..."`。'
|
|
200
279
|
);
|
|
201
280
|
}
|
|
202
|
-
|
|
203
|
-
|
|
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 留着,会话不死
|
|
281
|
+
const spec = KIND_CMD[kind];
|
|
282
|
+
const startCmd = continueSession ? spec.resume : spec.fresh;
|
|
216
283
|
return restartSkeleton(session, socketPath, {
|
|
217
|
-
targetKind:
|
|
284
|
+
targetKind: kind,
|
|
218
285
|
stopAction: () => {
|
|
219
|
-
|
|
220
|
-
|
|
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 {}
|
|
286
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(spec.exit)}`, EXEC_OPTS);
|
|
287
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
225
288
|
},
|
|
226
|
-
downTimeout:
|
|
227
|
-
startCmd
|
|
289
|
+
downTimeout: spec.downTimeout,
|
|
290
|
+
startCmd,
|
|
228
291
|
});
|
|
229
292
|
}
|
|
230
293
|
|
|
231
294
|
/**
|
|
232
|
-
* 关掉整个终端(kill-session)。返回关之前检测到的 kind
|
|
233
|
-
* 连带杀了 guardian)。
|
|
295
|
+
* 关掉整个终端(kill-session)。返回关之前检测到的 kind。
|
|
234
296
|
*/
|
|
235
297
|
function stopTerm(session, { socketPath } = {}) {
|
|
236
298
|
const tmux = tmuxCmd(socketPath);
|