@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.
Files changed (2) hide show
  1. package/lib/tmux-utils.cjs +111 -10
  2. package/package.json +1 -1
@@ -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
- // - resetKeys/resetWaitMs:发 exit 前先把 TUI 拉回干净空闲态(逐个键发送,每个键后等一小下)
22
- // - exit:TUI 内的优雅退出命令(restart reset 后再发它 + Enter)
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
- { targetKind, resetAction = async () => {}, stopAction, downTimeout, startCmd }
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. reset TUI,再优雅退出
213
- await resetAction();
214
- stopAction();
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 = `${targetKind} 未在 ${Math.round(downTimeout / 1000)}s 内优雅退出,已强制终止;`
221
- + (targetKind === 'codex' ? 'resume 的会话可能不完整' : '会话状态可能不完整');
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
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",