@seamnet/client 0.20.3 → 0.20.5
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/lib/tmux-utils.cjs +111 -10
- package/package.json +1 -1
package/lib/tmux-utils.cjs
CHANGED
|
@@ -15,24 +15,39 @@ const { basename } = require('node:path');
|
|
|
15
15
|
|
|
16
16
|
const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
17
17
|
|
|
18
|
+
function isCcBusyLine(line) {
|
|
19
|
+
return line.includes('⏵⏵') && /esc to interrupt/i.test(line);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isCodexBusyLine(line) {
|
|
23
|
+
return /^[^\S\r\n]*[◦•]\s+Working \([^)]*esc to interrupt[^)]*\)(?:\s*·.*)?$/i.test(line);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
// kind → 启动命令族。声明式数据小表,不是 adapter。新增 kind 加一行即可。
|
|
19
27
|
// - fresh:全新会话的启动命令
|
|
20
28
|
// - resume:续上一次会话的命令(restart 默认走这个;--no-continue 走 fresh)
|
|
21
|
-
// -
|
|
22
|
-
// - exit
|
|
29
|
+
// - busyLineMatchers/idleWaitMs/idleSettleMs:restart 先等 TUI 真空闲并短暂复核稳定;超时才退回 resetKeys 强推
|
|
30
|
+
// - resetKeys/resetWaitMs:仅兜底路径用;发 exit 前尝试把 TUI 从忙窗口里拉回可退出态
|
|
31
|
+
// - exit:TUI 内的优雅退出命令(空闲时直接发它 + Enter)
|
|
23
32
|
// - downTimeout:发了 exit 后等进程退出的轮询超时(ms)
|
|
24
33
|
const KIND_CMD = {
|
|
25
34
|
cc: {
|
|
26
35
|
fresh: 'claude --dangerously-skip-permissions',
|
|
27
36
|
resume: 'claude --dangerously-skip-permissions --continue',
|
|
37
|
+
busyLineMatchers: [isCcBusyLine],
|
|
38
|
+
idleWaitMs: 120000,
|
|
39
|
+
idleSettleMs: 500,
|
|
28
40
|
resetKeys: ['Escape'],
|
|
29
41
|
resetWaitMs: 1000,
|
|
30
42
|
exit: '/exit',
|
|
31
43
|
downTimeout: 120000, // cc 退出跑 Stop hook(auto-save / semantic_memory)要 ~90s
|
|
32
44
|
},
|
|
33
45
|
codex: {
|
|
34
|
-
fresh: 'codex',
|
|
35
|
-
resume: 'codex resume --last',
|
|
46
|
+
fresh: 'codex --dangerously-bypass-approvals-and-sandbox',
|
|
47
|
+
resume: 'codex resume --last --dangerously-bypass-approvals-and-sandbox',
|
|
48
|
+
busyLineMatchers: [isCodexBusyLine],
|
|
49
|
+
idleWaitMs: 60000,
|
|
50
|
+
idleSettleMs: 1000,
|
|
36
51
|
resetKeys: ['Escape', 'Escape'],
|
|
37
52
|
resetWaitMs: 1000,
|
|
38
53
|
exit: '/exit',
|
|
@@ -137,6 +152,62 @@ function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
|
|
|
137
152
|
return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
|
|
138
153
|
}
|
|
139
154
|
|
|
155
|
+
function readViewport(session, { lines = 80, socketPath } = {}) {
|
|
156
|
+
const tmux = tmuxCmd(socketPath);
|
|
157
|
+
const raw = execSync(
|
|
158
|
+
`${tmux} capture-pane -t "${session}" -p`,
|
|
159
|
+
{ ...EXEC_OPTS, maxBuffer: 4 * 1024 * 1024 }
|
|
160
|
+
);
|
|
161
|
+
const n = parseInt(lines, 10) || 80;
|
|
162
|
+
return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function matchesBusy(screenText, busyLineMatchers = []) {
|
|
166
|
+
if (!Array.isArray(busyLineMatchers) || busyLineMatchers.length === 0) return false;
|
|
167
|
+
return String(screenText)
|
|
168
|
+
.split('\n')
|
|
169
|
+
.some((line) => busyLineMatchers.some((matchesLine) => matchesLine(line)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function waitForIdle(
|
|
173
|
+
session,
|
|
174
|
+
socketPath,
|
|
175
|
+
{ targetKind, busyLineMatchers = [], idleWaitMs = 0, idleSettleMs = 0 }
|
|
176
|
+
) {
|
|
177
|
+
if (!(idleWaitMs > 0) || busyLineMatchers.length === 0) {
|
|
178
|
+
return { ok: true, lastScreen: '' };
|
|
179
|
+
}
|
|
180
|
+
const deadline = Date.now() + idleWaitMs;
|
|
181
|
+
let lastScreen = '';
|
|
182
|
+
let idleSince = null;
|
|
183
|
+
while (true) {
|
|
184
|
+
if (detectKind(session, socketPath) !== targetKind) {
|
|
185
|
+
return { ok: true, kindChanged: true, lastScreen };
|
|
186
|
+
}
|
|
187
|
+
lastScreen = readViewport(session, { socketPath });
|
|
188
|
+
if (!matchesBusy(lastScreen, busyLineMatchers)) {
|
|
189
|
+
if (!(idleSettleMs > 0)) {
|
|
190
|
+
return { ok: true, lastScreen };
|
|
191
|
+
}
|
|
192
|
+
if (idleSince == null) idleSince = Date.now();
|
|
193
|
+
if (Date.now() - idleSince >= idleSettleMs) {
|
|
194
|
+
return { ok: true, lastScreen };
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
idleSince = null;
|
|
198
|
+
}
|
|
199
|
+
if (Date.now() >= deadline) {
|
|
200
|
+
return { ok: false, lastScreen };
|
|
201
|
+
}
|
|
202
|
+
await sleep(1000);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendWarning(existing, next) {
|
|
207
|
+
if (!existing) return next;
|
|
208
|
+
return `${existing};${next}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
/**
|
|
141
212
|
* 给 session 发文本和/或命名按键。
|
|
142
213
|
* - text:字面文本(-l)。默认末尾自动 Enter;noEnter 或同时有 keys 时抑制自动 Enter。
|
|
@@ -204,21 +275,47 @@ function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
|
|
|
204
275
|
async function restartSkeleton(
|
|
205
276
|
session,
|
|
206
277
|
socketPath,
|
|
207
|
-
{
|
|
278
|
+
{
|
|
279
|
+
targetKind,
|
|
280
|
+
busyLineMatchers = [],
|
|
281
|
+
idleWaitMs = 0,
|
|
282
|
+
idleSettleMs = 0,
|
|
283
|
+
resetAction = async () => {},
|
|
284
|
+
stopAction,
|
|
285
|
+
downTimeout,
|
|
286
|
+
startCmd,
|
|
287
|
+
}
|
|
208
288
|
) {
|
|
209
289
|
const tmux = tmuxCmd(socketPath);
|
|
210
290
|
let warning = null;
|
|
211
291
|
|
|
212
|
-
// 1.
|
|
213
|
-
await
|
|
214
|
-
|
|
292
|
+
// 1. 先等真空闲;整段预算都没等到,才退回 resetKey 强推
|
|
293
|
+
const idleState = await waitForIdle(session, socketPath, {
|
|
294
|
+
targetKind,
|
|
295
|
+
busyLineMatchers,
|
|
296
|
+
idleWaitMs,
|
|
297
|
+
idleSettleMs,
|
|
298
|
+
});
|
|
299
|
+
if (!idleState.ok) {
|
|
300
|
+
warning = appendWarning(
|
|
301
|
+
warning,
|
|
302
|
+
'未等到空闲窗口,已改用中断后退出;可能打断记忆保存'
|
|
303
|
+
);
|
|
304
|
+
await resetAction();
|
|
305
|
+
}
|
|
306
|
+
if (detectKind(session, socketPath) === targetKind) {
|
|
307
|
+
stopAction();
|
|
308
|
+
}
|
|
215
309
|
|
|
216
310
|
// 2. poll-down:轮询直到 kind 不再是 targetKind;超时强制 kill 兜底
|
|
217
311
|
const downDeadline = Date.now() + downTimeout;
|
|
218
312
|
while (detectKind(session, socketPath) === targetKind) {
|
|
219
313
|
if (Date.now() >= downDeadline) {
|
|
220
|
-
warning =
|
|
221
|
-
|
|
314
|
+
warning = appendWarning(
|
|
315
|
+
warning,
|
|
316
|
+
`${targetKind} 未在 ${Math.round(downTimeout / 1000)}s 内优雅退出,已强制终止;`
|
|
317
|
+
+ (targetKind === 'codex' ? 'resume 的会话可能不完整' : '会话状态可能不完整')
|
|
318
|
+
);
|
|
222
319
|
try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
|
|
223
320
|
await sleep(2000);
|
|
224
321
|
if (detectKind(session, socketPath) === targetKind) {
|
|
@@ -292,6 +389,9 @@ async function restartTerm(session, { socketPath, continueSession = true } = {})
|
|
|
292
389
|
const startCmd = continueSession ? spec.resume : spec.fresh;
|
|
293
390
|
return restartSkeleton(session, socketPath, {
|
|
294
391
|
targetKind: kind,
|
|
392
|
+
busyLineMatchers: spec.busyLineMatchers,
|
|
393
|
+
idleWaitMs: spec.idleWaitMs,
|
|
394
|
+
idleSettleMs: spec.idleSettleMs,
|
|
295
395
|
resetAction: async () => {
|
|
296
396
|
if (Array.isArray(spec.resetKeys) && spec.resetKeys.length > 0) {
|
|
297
397
|
for (const key of spec.resetKeys) {
|
|
@@ -322,4 +422,5 @@ function stopTerm(session, { socketPath } = {}) {
|
|
|
322
422
|
module.exports = {
|
|
323
423
|
tmuxCmd, detectKind, listTerms, readTerm, sendTerm,
|
|
324
424
|
startTerm, restartTerm, stopTerm, KIND_CMD,
|
|
425
|
+
readViewport, matchesBusy, waitForIdle,
|
|
325
426
|
};
|