@seamnet/client 0.19.0 → 0.20.0

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 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 cc list',
61
- ' seam cc read --session <name> [--lines N]',
62
- ' seam cc send --session <name> --text <msg>',
63
- ' seam cc start --dir <path> [--session <name>]',
64
- ' seam cc restart --session <name> [--no-continue] 重启某个 CC 实例',
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
- // === cc (Claude Code 管理) ===
405
+ // === term (通用终端操作) ===
405
406
 
406
407
  const tmuxUtils = require('../lib/tmux-utils.cjs');
407
408
 
408
- async function cmdCc(subAction, restArgs) {
409
+ async function cmdTerm(subAction, restArgs) {
409
410
  if (!subAction || subAction === 'list') {
410
- const sessions = tmuxUtils.listSessions();
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.readOutput(values.session, parseInt(values.lines) || 20);
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
- if (!values.text) output(false, '--text required');
450
+ const keys = values.key || [];
451
+ if (values.text == null && keys.length === 0) {
452
+ output(false, '需要 --text 或 --key 至少一个');
453
+ }
446
454
  try {
447
- tmuxUtils.sendToCC(values.session, values.text);
448
- output(true, { session: values.session, sent: values.text });
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,51 @@ async function cmdCc(subAction, restArgs) {
456
468
  const { values } = parseArgs({
457
469
  args: restArgs,
458
470
  options: {
459
- dir: { type: 'string' },
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.dir) output(false, '--dir required');
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.startCC(values.dir, sessionName);
468
- output(true, { session: sessionName, dir: values.dir, started: true });
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
+ try {
503
+ const { readAll } = await import('../lib/registry.js');
504
+ guardianEntry = readAll().find((e) => e.tmux_session === values.session) || null;
505
+ } catch {}
506
+ try {
507
+ const { kind } = tmuxUtils.stopTerm(values.session);
508
+ const result = { session: values.session, kind, stopped: true };
509
+ if (guardianEntry) {
510
+ result.guardian_killed = true;
511
+ result.warning = `注意:已一并终止该 home 的 guardian(userId=${guardianEntry.userId}, home=${guardianEntry.seam_home})`;
512
+ } else if (kind === 'cc') {
513
+ result.warning = '注意:这是个 cc 会话——若它绑着 guardian,guardian 已随会话一并终止';
514
+ }
515
+ output(true, result);
469
516
  } catch (e) {
470
517
  output(false, e.message);
471
518
  }
@@ -485,11 +532,11 @@ async function cmdCc(subAction, restArgs) {
485
532
  if (!values.session) output(false, '--session required');
486
533
  const continueSession = values['no-continue'] ? false : true;
487
534
  try {
488
- const ok = await tmuxUtils.restartCC(values.session, { continueSession });
535
+ const ok = await tmuxUtils.restartTerm(values.session, { continueSession });
489
536
  if (ok) {
490
537
  output(true, { session: values.session, restarted: true });
491
538
  } else {
492
- output(false, 'restart failed: CC 未在超时内退出或重启');
539
+ output(false, 'restart failed: 终端未在超时内退出或重启');
493
540
  }
494
541
  } catch (e) {
495
542
  output(false, e.message);
@@ -497,7 +544,7 @@ async function cmdCc(subAction, restArgs) {
497
544
  return;
498
545
  }
499
546
 
500
- output(false, `Unknown cc action: ${subAction}. Try: list, read, send, start, restart`);
547
+ output(false, `Unknown term action: ${subAction}. Try: list, read, send, start, stop, restart`);
501
548
  }
502
549
 
503
550
  function parseDuration(str) {
@@ -529,7 +576,7 @@ function parseDuration(str) {
529
576
  if (domain === 'inbox') return await cmdInbox(action, rest);
530
577
  if (domain === 'self') return await cmdSelf(action, rest);
531
578
  if (domain === 'invite') return await cmdInvite(action, rest);
532
- if (domain === 'cc') return await cmdCc(action, rest);
579
+ if (domain === 'term') return await cmdTerm(action, rest);
533
580
  output(false, `Unknown domain: ${domain}. Try: seam --help`);
534
581
  } catch (e) {
535
582
  output(false, e.message);
package/lib/guardian.js CHANGED
@@ -252,14 +252,14 @@ export async function guardianRun() {
252
252
  hub.inject(notice);
253
253
  setTimeout(async () => {
254
254
  try {
255
- const { restartCC } = require('./tmux-utils.cjs');
256
- const ok = await restartCC(safeSession, { socketPath: ccSocket, continueSession: true });
255
+ const { restartTerm } = require('./tmux-utils.cjs');
256
+ const ok = await restartTerm(safeSession, { socketPath: ccSocket, continueSession: true });
257
257
  if (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
261
  } else {
262
- hub.logger('guardian').error('cc_restart_failed', new Error('restartCC returned false'));
262
+ hub.logger('guardian').error('cc_restart_failed', new Error('restartTerm returned false'));
263
263
  }
264
264
  } catch (e) {
265
265
  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 sendToCC 注入;
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 { sendToCC } = require('./tmux-utils.cjs');
43
+ const { sendTerm } = require('./tmux-utils.cjs');
44
44
  const injectText = formatLocalInject(fromName, text);
45
- sendToCC(entry.tmux_session, injectText, { socketPath: entry.tmux_socket || undefined });
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
@@ -1,157 +1,245 @@
1
1
  /**
2
- * tmux-utils — 可靠的 tmux 交互。
2
+ * tmux-utils — 通用 tmux 终端操作(transport 层)。
3
3
  *
4
- * CC 用 raw terminal mode,tmux send-keys 需要特殊处理:
5
- * 1. 文本用 -l (literal) 发,不解释特殊键
6
- * 2. Enter 单独发
7
- * 3. 发完验证是否被接收
4
+ * 只做 transport:读屏(capture-pane)、发键(send-keys)、起/停/重启会话。
5
+ * 这层与 pane 里跑什么无关。交互语义(审批流、idle 判断等)故意不抽象——
6
+ * 由操作者读屏自己决定按什么键。
7
+ *
8
+ * 唯一的 per-kind 知识:KIND_CMD 表(kind → 默认启动命令);restart 时
9
+ * cc 走优雅 /exit(跑 Stop hook 保命),codex 走 kill 重起。
8
10
  */
9
11
 
10
12
  const { execSync } = require('node:child_process');
11
- const { existsSync, readFileSync } = require('node:fs');
12
- const { join } = require('node:path');
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 → 默认启动命令。新增 kind 在这里加一行即可(kind 是数据,不是命令树)。
19
+ const KIND_CMD = {
20
+ cc: 'claude --dangerously-skip-permissions',
21
+ codex: 'codex -m gpt-5.4',
22
+ shell: 'bash',
23
+ };
24
+ const KIND_PREFIX = { cc: 'cc', codex: 'codex', shell: 'sh' };
25
+
16
26
  function tmuxCmd(socketPath) {
17
27
  return socketPath ? `tmux -S "${socketPath}"` : 'tmux';
18
28
  }
19
29
 
20
- function listSessions(socketPath) {
30
+ function sleep(ms) {
31
+ return new Promise((r) => setTimeout(r, ms));
32
+ }
33
+
34
+ /**
35
+ * 检测 session 里跑的是什么:'cc' | 'codex' | 'shell'。session 不存在返回 null。
36
+ * 按 pane 进程及其子进程的 comm 判断。
37
+ */
38
+ function detectKind(session, socketPath) {
21
39
  const tmux = tmuxCmd(socketPath);
22
- let lines;
40
+ let panePid;
23
41
  try {
24
- lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
25
- .trim().split('\n').filter(Boolean);
42
+ panePid = execSync(`${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS).trim();
26
43
  } catch {
27
- return [];
44
+ return null;
28
45
  }
29
- return lines.map(name => {
30
- const cc = isCCRunning(name, socketPath);
31
- return { session: name, cc };
32
- });
33
- }
34
-
35
- function isCCRunning(session, socketPath) {
36
- const tmux = tmuxCmd(socketPath);
46
+ const comms = [];
37
47
  try {
38
- const panePid = execSync(
39
- `${tmux} display-message -t "${session}" -p '#{pane_pid}'`, EXEC_OPTS
40
- ).trim();
41
- const self = execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, EXEC_OPTS).trim();
42
- if (['claude', 'claude-code'].includes(self)) return true;
43
- try {
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
+ comms.push(execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, EXEC_OPTS).trim());
48
49
  } catch {}
49
- return false;
50
+ try {
51
+ const children = execSync(`ps -o comm= --ppid ${panePid} 2>/dev/null`, EXEC_OPTS).trim();
52
+ if (children) comms.push(children);
53
+ } catch {}
54
+ const joined = comms.join(' ').toLowerCase();
55
+ if (joined.includes('claude')) return 'cc';
56
+ if (joined.includes('codex')) return 'codex';
57
+ return 'shell';
50
58
  }
51
59
 
52
60
  /**
53
- * tmux session 里的 CC 发用户消息。
54
- * 用 -l (literal) 发文本 + 单独 Enter,保证 raw terminal mode 下不丢回车。
61
+ * 列出所有 tmux 会话 + 每个的 kind。
55
62
  */
56
- function sendToCC(session, text, { socketPath, skipCheck = false } = {}) {
63
+ function listTerms(socketPath) {
57
64
  const tmux = tmuxCmd(socketPath);
58
- if (!skipCheck && !isCCRunning(session, socketPath)) {
59
- throw new Error(`session "${session}": CC not running`);
65
+ let lines;
66
+ try {
67
+ lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
68
+ .trim().split('\n').filter(Boolean);
69
+ } catch {
70
+ return [];
60
71
  }
61
- // -l 发纯文本(不解释特殊键)
62
- execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
63
- // 单独发 Enter 提交
64
- execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
72
+ return lines.map((name) => ({ session: name, kind: detectKind(name, socketPath) }));
65
73
  }
66
74
 
67
75
  /**
68
- * 读 tmux session 的最后 N 行输出。
76
+ * 读 session 屏幕。默认 last N 行;full=true 读整屏 + scrollback 历史。
77
+ *
78
+ * 实现:一律 `capture-pane -p -S -` 抓全量,在 JS 里去掉尾部空行再切最后 N 行。
79
+ * 不用 shell `| tail`——capture-pane 会把光标下方的空 viewport 行也输出,
80
+ * shell 的 tail 先抓到那些空行,内容稀疏的会话(shell / 刚起的 codex)就读不到东西。
81
+ * capture-pane 受 tmux history-limit 限制(默认 2000 行),输出有界。
69
82
  */
70
- function readOutput(session, lines = 20, socketPath) {
83
+ function readTerm(session, { lines = 20, full = false, socketPath } = {}) {
71
84
  const tmux = tmuxCmd(socketPath);
72
- return execSync(
73
- `${tmux} capture-pane -t "${session}" -p | tail -${parseInt(lines) || 20}`,
74
- EXEC_OPTS
85
+ const raw = execSync(
86
+ `${tmux} capture-pane -t "${session}" -p -S -`,
87
+ { ...EXEC_OPTS, maxBuffer: 16 * 1024 * 1024 }
75
88
  );
89
+ if (full) return raw;
90
+ const n = parseInt(lines, 10) || 20;
91
+ return raw.replace(/\s+$/, '').split('\n').slice(-n).join('\n');
76
92
  }
77
93
 
78
94
  /**
79
- * 在新 tmux session 里启动 CC。
95
+ * session 发文本和/或命名按键。
96
+ * - text:字面文本(-l)。默认末尾自动 Enter;noEnter 或同时有 keys 时抑制自动 Enter。
97
+ * - keys:tmux 命名按键(C-c / Enter / Escape / Up / Tab ...),按顺序发,不加 Enter。
98
+ * 二者可叠加:text 先发,再按顺序发 keys。
80
99
  */
81
- function startCC(dir, session, socketPath) {
100
+ function sendTerm(session, { text, noEnter = false, keys = [], socketPath } = {}) {
82
101
  const tmux = tmuxCmd(socketPath);
83
- if (!existsSync(dir)) throw new Error(`dir not found: ${dir}`);
102
+ const hasText = text != null && text !== '';
103
+ const hasKeys = Array.isArray(keys) && keys.length > 0;
104
+ if (!hasText && !hasKeys) {
105
+ throw new Error('sendTerm: 需要 text 或 keys 至少一个');
106
+ }
107
+ if (hasText) {
108
+ execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(text)}`, EXEC_OPTS);
109
+ // 自动 Enter:仅 text-only 且未抑制时。混发按键 = 手动模式,不惊喜回车。
110
+ if (!noEnter && !hasKeys) {
111
+ execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
112
+ }
113
+ }
114
+ for (const k of (hasKeys ? keys : [])) {
115
+ execSync(`${tmux} send-keys -t "${session}" ${JSON.stringify(k)}`, EXEC_OPTS);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 新建 tmux 会话并在里面起东西。会话名带 kind 前缀(cc-/codex-/sh-)。
121
+ * 会话先跑 bash,再 send 启动命令进去——这样程序退出后 bash 还在、
122
+ * 会话不死,restart 能在原会话里重起。
123
+ */
124
+ function startTerm({ kind, cmd, session, dir, socketPath } = {}) {
125
+ const tmux = tmuxCmd(socketPath);
126
+ if (!kind || !(kind in KIND_CMD)) {
127
+ throw new Error(`startTerm: --kind 必须是 ${Object.keys(KIND_CMD).join('|')}`);
128
+ }
129
+ if (dir && !existsSync(dir)) throw new Error(`dir not found: ${dir}`);
130
+ const base = session || (dir ? basename(dir) : null);
131
+ if (!base) throw new Error('startTerm: 需要 --session 或 --dir');
132
+ const sessionName = `${KIND_PREFIX[kind]}-${base}`;
84
133
  try {
85
- execSync(`${tmux} has-session -t "${session}" 2>/dev/null`, EXEC_OPTS);
86
- throw new Error(`session "${session}" already exists`);
134
+ execSync(`${tmux} has-session -t "${sessionName}" 2>/dev/null`, EXEC_OPTS);
135
+ throw new Error(`session "${sessionName}" already exists`);
87
136
  } catch (e) {
88
137
  if (e.message.includes('already exists')) throw e;
89
138
  }
90
- execSync(
91
- `${tmux} new-session -d -s "${session}" -c "${dir}" 'claude --dangerously-skip-permissions'`,
92
- { ...EXEC_OPTS, timeout: 10000 }
93
- );
94
- }
95
-
96
- function sleep(ms) {
97
- return new Promise((r) => setTimeout(r, ms));
139
+ const cwdArg = dir ? `-c "${dir}"` : '';
140
+ execSync(`${tmux} new-session -d -s "${sessionName}" ${cwdArg}`.trim(), { ...EXEC_OPTS, timeout: 10000 });
141
+ // kind=shell 且无 --cmd:new-session 本身就是 bash,不必再 send。
142
+ const startCmd = cmd || KIND_CMD[kind];
143
+ if (cmd || kind !== 'shell') {
144
+ execSync(`${tmux} send-keys -t "${sessionName}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
145
+ execSync(`${tmux} send-keys -t "${sessionName}" Enter`, EXEC_OPTS);
146
+ }
147
+ return { session: sessionName, kind, cmd: startCmd };
98
148
  }
99
149
 
100
150
  /**
101
- * CC /exit 然后重启(带 --continue)。
102
- * guardian 升级后自动重启 CC 用这个。
103
- *
104
- * 不再固定等 3s——有 Stop hook(auto-save / semantic_memory)的 AI 退出要
105
- * 60-90s,3s 时旧 claude 还活着,重启命令会被当成旧 TUI 的 queued message,
106
- * CC 永远没重启。改成轮询 isCCRunning 确认状态再动作。
151
+ * restart 通用骨架:停 轮询确认停了 起 → 轮询确认起了 → 失败 C-c 重试一次。
152
+ * 0.17.1 的「别在状态真变之前动作」教训对所有 kind 通用——差别只在「怎么停」。
107
153
  */
108
- async function restartCC(session, { socketPath, continueSession = true } = {}) {
154
+ async function restartSkeleton(session, socketPath, { targetKind, stopAction, downTimeout, startCmd }) {
109
155
  const tmux = tmuxCmd(socketPath);
110
- const restartCmd = continueSession
111
- ? 'claude --dangerously-skip-permissions --continue'
112
- : 'claude --dangerously-skip-permissions';
113
-
114
- // 1. /exit
115
- execSync(`${tmux} send-keys -t "${session}" -l '/exit'`, EXEC_OPTS);
116
- execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
117
-
118
- // 2. 轮询直到 CC 退出(120s 覆盖 90s Stop hook + 余量)
119
- const exitDeadline = Date.now() + 120000;
120
- while (isCCRunning(session, socketPath)) {
121
- if (Date.now() >= exitDeadline) {
122
- // 旧 CC 还没退,发重启命令会变成 queued message 污染 TUI——放弃
123
- return false;
124
- }
156
+ stopAction();
157
+ // poll-down:轮询直到 kind 不再是 targetKind
158
+ const downDeadline = Date.now() + downTimeout;
159
+ while (detectKind(session, socketPath) === targetKind) {
160
+ if (Date.now() >= downDeadline) return false;
125
161
  await sleep(1000);
126
162
  }
127
-
128
- // 3. 退出确认后等 shell prompt 落定
129
- await sleep(500);
130
-
131
- // 4-5. 发重启命令 + 轮询确认 CC 起来(30s 超时)
163
+ await sleep(500); // 等 shell prompt 落定
164
+ // + poll-up(30s)
132
165
  const tryStart = async () => {
133
166
  try {
134
- execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(restartCmd)}`, EXEC_OPTS);
167
+ execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(startCmd)}`, EXEC_OPTS);
135
168
  execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
136
169
  } catch {
137
170
  return false;
138
171
  }
139
172
  const upDeadline = Date.now() + 30000;
140
- while (!isCCRunning(session, socketPath)) {
173
+ while (detectKind(session, socketPath) !== targetKind) {
141
174
  if (Date.now() >= upDeadline) return false;
142
175
  await sleep(1000);
143
176
  }
144
177
  return true;
145
178
  };
146
-
147
179
  if (await tryStart()) return true;
148
-
149
- // 6. 超时没起来:C-c 清掉可能的半行,重试一次
150
- try {
151
- execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS);
152
- } catch {}
180
+ // 没起来:C-c 清掉可能的半行,重试一次
181
+ try { execSync(`${tmux} send-keys -t "${session}" C-c`, EXEC_OPTS); } catch {}
153
182
  await sleep(500);
154
183
  return tryStart();
155
184
  }
156
185
 
157
- module.exports = { listSessions, isCCRunning, sendToCC, readOutput, startCC, restartCC, tmuxCmd };
186
+ /**
187
+ * 重启 session 里跑的东西。只对 cc / codex 有意义——非 cc/codex(shell 或
188
+ * 自定义命令)直接报错,不静默重启成 bash。
189
+ *
190
+ * cc:优雅 /exit(跑 Stop hook,120s 超时覆盖 auto-save/semantic_memory)。
191
+ * codex:kill 进程(30s 短超时)。两者都轮询确认停了再起。
192
+ */
193
+ async function restartTerm(session, { socketPath, continueSession = true } = {}) {
194
+ const tmux = tmuxCmd(socketPath);
195
+ const kind = detectKind(session, socketPath);
196
+ if (kind !== 'cc' && kind !== 'codex') {
197
+ throw new Error(
198
+ `"${session}" 不是 cc/codex 会话(检测到 kind=${kind === null ? '会话不存在' : kind}),`
199
+ + 'restart 不知道该用什么命令重启它。要重起请用 `seam term start --kind ... --cmd "..."`。'
200
+ );
201
+ }
202
+ if (kind === 'cc') {
203
+ const startCmd = continueSession ? `${KIND_CMD.cc} --continue` : KIND_CMD.cc;
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 留着,会话不死
216
+ return restartSkeleton(session, socketPath, {
217
+ targetKind: 'codex',
218
+ stopAction: () => {
219
+ try {
220
+ const panePid = execSync(
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 {}
225
+ },
226
+ downTimeout: 30000,
227
+ startCmd: KIND_CMD.codex,
228
+ });
229
+ }
230
+
231
+ /**
232
+ * 关掉整个终端(kill-session)。返回关之前检测到的 kind(供调用方判断是否
233
+ * 连带杀了 guardian)。
234
+ */
235
+ function stopTerm(session, { socketPath } = {}) {
236
+ const tmux = tmuxCmd(socketPath);
237
+ const kind = detectKind(session, socketPath);
238
+ execSync(`${tmux} kill-session -t "${session}"`, EXEC_OPTS);
239
+ return { kind };
240
+ }
241
+
242
+ module.exports = {
243
+ tmuxCmd, detectKind, listTerms, readTerm, sendTerm,
244
+ startTerm, restartTerm, stopTerm, KIND_CMD,
245
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",