@seamnet/client 0.20.3 → 0.20.4

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 +98 -8
  2. package/package.json +1 -1
@@ -18,13 +18,17 @@ const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pi
18
18
  // kind → 启动命令族。声明式数据小表,不是 adapter。新增 kind 加一行即可。
19
19
  // - fresh:全新会话的启动命令
20
20
  // - resume:续上一次会话的命令(restart 默认走这个;--no-continue 走 fresh)
21
- // - resetKeys/resetWaitMs:发 exit 前先把 TUI 拉回干净空闲态(逐个键发送,每个键后等一小下)
22
- // - exit:TUI 内的优雅退出命令(restart reset 后再发它 + Enter)
21
+ // - busyPatterns/idleWaitMs/idleSettleMs:restart 先等 TUI 真空闲并短暂复核稳定;超时才退回 resetKeys 强推
22
+ // - resetKeys/resetWaitMs:仅兜底路径用;发 exit 前尝试把 TUI 从忙窗口里拉回可退出态
23
+ // - exit:TUI 内的优雅退出命令(空闲时直接发它 + Enter)
23
24
  // - downTimeout:发了 exit 后等进程退出的轮询超时(ms)
24
25
  const KIND_CMD = {
25
26
  cc: {
26
27
  fresh: 'claude --dangerously-skip-permissions',
27
28
  resume: 'claude --dangerously-skip-permissions --continue',
29
+ busyPatterns: [/esc to interrupt/i],
30
+ idleWaitMs: 120000,
31
+ idleSettleMs: 500,
28
32
  resetKeys: ['Escape'],
29
33
  resetWaitMs: 1000,
30
34
  exit: '/exit',
@@ -33,6 +37,9 @@ const KIND_CMD = {
33
37
  codex: {
34
38
  fresh: 'codex',
35
39
  resume: 'codex resume --last',
40
+ busyPatterns: [/esc to interrupt/i, /Working \(/i],
41
+ idleWaitMs: 60000,
42
+ idleSettleMs: 1000,
36
43
  resetKeys: ['Escape', 'Escape'],
37
44
  resetWaitMs: 1000,
38
45
  exit: '/exit',
@@ -137,6 +144,59 @@ function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
137
144
  return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
138
145
  }
139
146
 
147
+ function readViewport(session, { lines = 80, socketPath } = {}) {
148
+ const tmux = tmuxCmd(socketPath);
149
+ const raw = execSync(
150
+ `${tmux} capture-pane -t "${session}" -p`,
151
+ { ...EXEC_OPTS, maxBuffer: 4 * 1024 * 1024 }
152
+ );
153
+ const n = parseInt(lines, 10) || 80;
154
+ return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
155
+ }
156
+
157
+ function matchesBusy(screenText, busyPatterns = []) {
158
+ return busyPatterns.some((pattern) => pattern.test(screenText));
159
+ }
160
+
161
+ async function waitForIdle(
162
+ session,
163
+ socketPath,
164
+ { targetKind, busyPatterns = [], idleWaitMs = 0, idleSettleMs = 0 }
165
+ ) {
166
+ if (!(idleWaitMs > 0) || busyPatterns.length === 0) {
167
+ return { ok: true, lastScreen: '' };
168
+ }
169
+ const deadline = Date.now() + idleWaitMs;
170
+ let lastScreen = '';
171
+ let idleSince = null;
172
+ while (true) {
173
+ if (detectKind(session, socketPath) !== targetKind) {
174
+ return { ok: true, kindChanged: true, lastScreen };
175
+ }
176
+ lastScreen = readViewport(session, { socketPath });
177
+ if (!matchesBusy(lastScreen, busyPatterns)) {
178
+ if (!(idleSettleMs > 0)) {
179
+ return { ok: true, lastScreen };
180
+ }
181
+ if (idleSince == null) idleSince = Date.now();
182
+ if (Date.now() - idleSince >= idleSettleMs) {
183
+ return { ok: true, lastScreen };
184
+ }
185
+ } else {
186
+ idleSince = null;
187
+ }
188
+ if (Date.now() >= deadline) {
189
+ return { ok: false, lastScreen };
190
+ }
191
+ await sleep(1000);
192
+ }
193
+ }
194
+
195
+ function appendWarning(existing, next) {
196
+ if (!existing) return next;
197
+ return `${existing};${next}`;
198
+ }
199
+
140
200
  /**
141
201
  * 给 session 发文本和/或命名按键。
142
202
  * - text:字面文本(-l)。默认末尾自动 Enter;noEnter 或同时有 keys 时抑制自动 Enter。
@@ -204,21 +264,47 @@ function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
204
264
  async function restartSkeleton(
205
265
  session,
206
266
  socketPath,
207
- { targetKind, resetAction = async () => {}, stopAction, downTimeout, startCmd }
267
+ {
268
+ targetKind,
269
+ busyPatterns = [],
270
+ idleWaitMs = 0,
271
+ idleSettleMs = 0,
272
+ resetAction = async () => {},
273
+ stopAction,
274
+ downTimeout,
275
+ startCmd,
276
+ }
208
277
  ) {
209
278
  const tmux = tmuxCmd(socketPath);
210
279
  let warning = null;
211
280
 
212
- // 1. reset TUI,再优雅退出
213
- await resetAction();
214
- stopAction();
281
+ // 1. 先等真空闲;整段预算都没等到,才退回 resetKey 强推
282
+ const idleState = await waitForIdle(session, socketPath, {
283
+ targetKind,
284
+ busyPatterns,
285
+ idleWaitMs,
286
+ idleSettleMs,
287
+ });
288
+ if (!idleState.ok) {
289
+ warning = appendWarning(
290
+ warning,
291
+ '未等到空闲窗口,已改用中断后退出;可能打断记忆保存'
292
+ );
293
+ await resetAction();
294
+ }
295
+ if (detectKind(session, socketPath) === targetKind) {
296
+ stopAction();
297
+ }
215
298
 
216
299
  // 2. poll-down:轮询直到 kind 不再是 targetKind;超时强制 kill 兜底
217
300
  const downDeadline = Date.now() + downTimeout;
218
301
  while (detectKind(session, socketPath) === targetKind) {
219
302
  if (Date.now() >= downDeadline) {
220
- warning = `${targetKind} 未在 ${Math.round(downTimeout / 1000)}s 内优雅退出,已强制终止;`
221
- + (targetKind === 'codex' ? 'resume 的会话可能不完整' : '会话状态可能不完整');
303
+ warning = appendWarning(
304
+ warning,
305
+ `${targetKind} 未在 ${Math.round(downTimeout / 1000)}s 内优雅退出,已强制终止;`
306
+ + (targetKind === 'codex' ? 'resume 的会话可能不完整' : '会话状态可能不完整')
307
+ );
222
308
  try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
223
309
  await sleep(2000);
224
310
  if (detectKind(session, socketPath) === targetKind) {
@@ -292,6 +378,9 @@ async function restartTerm(session, { socketPath, continueSession = true } = {})
292
378
  const startCmd = continueSession ? spec.resume : spec.fresh;
293
379
  return restartSkeleton(session, socketPath, {
294
380
  targetKind: kind,
381
+ busyPatterns: spec.busyPatterns,
382
+ idleWaitMs: spec.idleWaitMs,
383
+ idleSettleMs: spec.idleSettleMs,
295
384
  resetAction: async () => {
296
385
  if (Array.isArray(spec.resetKeys) && spec.resetKeys.length > 0) {
297
386
  for (const key of spec.resetKeys) {
@@ -322,4 +411,5 @@ function stopTerm(session, { socketPath } = {}) {
322
411
  module.exports = {
323
412
  tmuxCmd, detectKind, listTerms, readTerm, sendTerm,
324
413
  startTerm, restartTerm, stopTerm, KIND_CMD,
414
+ readViewport, matchesBusy, waitForIdle,
325
415
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.20.3",
3
+ "version": "0.20.4",
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",