@seamnet/client 0.14.5 → 0.16.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/cli.js +26 -12
- package/bin/seam.js +34 -65
- package/lib/guardian.js +42 -28
- package/lib/init.js +25 -2
- package/lib/paths.js +19 -2
- package/lib/registry.js +101 -0
- package/lib/tell.js +67 -0
- package/lib/tmux-utils.cjs +122 -0
- package/lib/upgrade-all.js +67 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import {
|
|
5
|
-
import { status } from '../lib/status.js';
|
|
6
|
-
import { stop } from '../lib/stop.js';
|
|
4
|
+
import { join } from 'node:path';
|
|
7
5
|
|
|
8
6
|
const command = process.argv[2];
|
|
9
7
|
|
|
@@ -12,13 +10,19 @@ if (!command || command === '--help' || command === '-h') {
|
|
|
12
10
|
seam-client — join the Seam network
|
|
13
11
|
|
|
14
12
|
Commands:
|
|
15
|
-
init
|
|
16
|
-
|
|
17
|
-
guardian
|
|
18
|
-
autostart
|
|
19
|
-
status
|
|
20
|
-
stop
|
|
21
|
-
|
|
13
|
+
init --invite-code <code> --name <name> Register and join Seam
|
|
14
|
+
--api <url> API base (default: $SEAM_API_BASE || https://seam.chat)
|
|
15
|
+
guardian start|stop|run Manage guardian background process
|
|
16
|
+
autostart Start guardian if credentials exist and not running (CC SessionStart hook)
|
|
17
|
+
status Check Seam status
|
|
18
|
+
stop Stop guardian
|
|
19
|
+
upgrade Upgrade this AI's seam-client (per-AI mode)
|
|
20
|
+
upgrade-all Host-tool: bump global package + restart all registered guardians
|
|
21
|
+
mcp-serve Start MCP server (used by Claude Code)
|
|
22
|
+
|
|
23
|
+
Environment:
|
|
24
|
+
SEAM_HOME Absolute path to a .seam data directory. Resolution priority:
|
|
25
|
+
1) $SEAM_HOME, 2) cwd-upwalk for .seam/, 3) $HOME/.seam fallback.
|
|
22
26
|
|
|
23
27
|
Example:
|
|
24
28
|
npx seam-client init --invite-code ABC123 --name my-ai
|
|
@@ -46,8 +50,11 @@ try {
|
|
|
46
50
|
console.error('Error: --name is required');
|
|
47
51
|
process.exit(1);
|
|
48
52
|
}
|
|
49
|
-
|
|
53
|
+
if (!process.env.SEAM_HOME) {
|
|
54
|
+
process.env.SEAM_HOME = join(process.cwd(), '.seam');
|
|
55
|
+
}
|
|
50
56
|
const apiBase = values.api || process.env.SEAM_API_BASE || 'https://seam.chat';
|
|
57
|
+
const { init } = await import('../lib/init.js');
|
|
51
58
|
await init({
|
|
52
59
|
inviteCode: values['invite-code'],
|
|
53
60
|
name: values.name,
|
|
@@ -55,14 +62,21 @@ try {
|
|
|
55
62
|
});
|
|
56
63
|
break;
|
|
57
64
|
}
|
|
58
|
-
case 'status':
|
|
65
|
+
case 'status': {
|
|
66
|
+
const { status } = await import('../lib/status.js');
|
|
59
67
|
await status();
|
|
60
68
|
break;
|
|
69
|
+
}
|
|
61
70
|
case 'upgrade': {
|
|
62
71
|
const { upgrade } = await import('../lib/upgrade.js');
|
|
63
72
|
await upgrade();
|
|
64
73
|
break;
|
|
65
74
|
}
|
|
75
|
+
case 'upgrade-all': {
|
|
76
|
+
const { upgradeAll } = await import('../lib/upgrade-all.js');
|
|
77
|
+
await upgradeAll();
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
66
80
|
case 'stop': {
|
|
67
81
|
const { guardianStop } = await import('../lib/guardian.js');
|
|
68
82
|
await guardianStop();
|
package/bin/seam.js
CHANGED
|
@@ -47,6 +47,7 @@ function printHelp() {
|
|
|
47
47
|
' seam msg group --group <id> (--text <text> | --text-file <path> | --image <path>)',
|
|
48
48
|
' seam contacts list',
|
|
49
49
|
' seam contacts get --user <userId>',
|
|
50
|
+
' seam tell --to <id|alias> [--from <name>] --text <text>',
|
|
50
51
|
' seam wechat bind',
|
|
51
52
|
' seam wechat send (--text <text> | --text-file <path> | --image <path> | --file <path>)',
|
|
52
53
|
' seam wechat status',
|
|
@@ -169,6 +170,30 @@ async function cmdMsgSend(restArgs) {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
async function cmdTell(restArgs) {
|
|
174
|
+
const { values } = parseArgs({
|
|
175
|
+
args: restArgs,
|
|
176
|
+
options: {
|
|
177
|
+
to: { type: 'string' },
|
|
178
|
+
from: { type: 'string' },
|
|
179
|
+
text: { type: 'string' },
|
|
180
|
+
'text-file': { type: 'string' },
|
|
181
|
+
},
|
|
182
|
+
strict: false,
|
|
183
|
+
});
|
|
184
|
+
if (!values.to) output(false, '--to required');
|
|
185
|
+
const text = readText({ text: values.text, textFile: values['text-file'] });
|
|
186
|
+
if (text == null) output(false, '--text or --text-file required');
|
|
187
|
+
const { tell } = await import('../lib/tell.js');
|
|
188
|
+
const res = await tell({
|
|
189
|
+
to: values.to,
|
|
190
|
+
from: values.from,
|
|
191
|
+
text,
|
|
192
|
+
guardianRequest,
|
|
193
|
+
});
|
|
194
|
+
output(res.ok !== false, res);
|
|
195
|
+
}
|
|
196
|
+
|
|
172
197
|
async function cmdMsgGroup(restArgs) {
|
|
173
198
|
const { values } = parseArgs({
|
|
174
199
|
args: restArgs,
|
|
@@ -377,43 +402,11 @@ async function cmdInvite(subAction, restArgs) {
|
|
|
377
402
|
|
|
378
403
|
// === cc (Claude Code 管理) ===
|
|
379
404
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
function ccGetSessions() {
|
|
383
|
-
let lines;
|
|
384
|
-
try {
|
|
385
|
-
lines = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
386
|
-
encoding: 'utf8', timeout: 5000,
|
|
387
|
-
}).trim().split('\n').filter(Boolean);
|
|
388
|
-
} catch {
|
|
389
|
-
return [];
|
|
390
|
-
}
|
|
391
|
-
return lines.map(name => {
|
|
392
|
-
let ccRunning = false;
|
|
393
|
-
try {
|
|
394
|
-
const panePid = execSync(`tmux display-message -t "${name}" -p '#{pane_pid}'`, {
|
|
395
|
-
encoding: 'utf8', timeout: 3000,
|
|
396
|
-
}).trim();
|
|
397
|
-
// pane 进程本身可能就是 claude(tmux 直接启动时),也可能是 bash 的子进程
|
|
398
|
-
const self = execSync(`ps -o comm= -p ${panePid} 2>/dev/null`, {
|
|
399
|
-
encoding: 'utf8', timeout: 3000,
|
|
400
|
-
}).trim();
|
|
401
|
-
let child = '';
|
|
402
|
-
try {
|
|
403
|
-
child = execSync(`ps -o comm= --ppid ${panePid} 2>/dev/null`, {
|
|
404
|
-
encoding: 'utf8', timeout: 3000,
|
|
405
|
-
}).trim().split('\n').pop() || '';
|
|
406
|
-
} catch {}
|
|
407
|
-
ccRunning = ['claude', 'claude-code'].includes(self) ||
|
|
408
|
-
['claude', 'claude-code'].includes(child);
|
|
409
|
-
} catch {}
|
|
410
|
-
return { session: name, cc: ccRunning };
|
|
411
|
-
});
|
|
412
|
-
}
|
|
405
|
+
const tmuxUtils = require('../lib/tmux-utils.cjs');
|
|
413
406
|
|
|
414
407
|
async function cmdCc(subAction, restArgs) {
|
|
415
408
|
if (!subAction || subAction === 'list') {
|
|
416
|
-
const sessions =
|
|
409
|
+
const sessions = tmuxUtils.listSessions();
|
|
417
410
|
if (sessions.length === 0) output(true, { sessions: [], message: 'no tmux sessions' });
|
|
418
411
|
output(true, { sessions });
|
|
419
412
|
return;
|
|
@@ -430,10 +423,7 @@ async function cmdCc(subAction, restArgs) {
|
|
|
430
423
|
});
|
|
431
424
|
if (!values.session) output(false, '--session required');
|
|
432
425
|
try {
|
|
433
|
-
const text =
|
|
434
|
-
`tmux capture-pane -t "${values.session}" -p | tail -${parseInt(values.lines) || 20}`,
|
|
435
|
-
{ encoding: 'utf8', timeout: 5000 }
|
|
436
|
-
);
|
|
426
|
+
const text = tmuxUtils.readOutput(values.session, parseInt(values.lines) || 20);
|
|
437
427
|
output(true, { session: values.session, lines: text.trimEnd().split('\n') });
|
|
438
428
|
} catch (e) {
|
|
439
429
|
output(false, `read failed: ${e.message}`);
|
|
@@ -452,24 +442,11 @@ async function cmdCc(subAction, restArgs) {
|
|
|
452
442
|
});
|
|
453
443
|
if (!values.session) output(false, '--session required');
|
|
454
444
|
if (!values.text) output(false, '--text required');
|
|
455
|
-
const sessions = ccGetSessions();
|
|
456
|
-
const target = sessions.find(s => s.session === values.session);
|
|
457
|
-
if (!target) output(false, `session "${values.session}" not found`);
|
|
458
|
-
if (!target.cc) output(false, `session "${values.session}": CC not running`);
|
|
459
445
|
try {
|
|
460
|
-
|
|
461
|
-
// CC 用 raw terminal mode,合在一起发会丢回车
|
|
462
|
-
execSync(
|
|
463
|
-
`tmux send-keys -t "${values.session}" -l ${JSON.stringify(values.text)}`,
|
|
464
|
-
{ timeout: 5000 }
|
|
465
|
-
);
|
|
466
|
-
execSync(
|
|
467
|
-
`tmux send-keys -t "${values.session}" Enter`,
|
|
468
|
-
{ timeout: 5000 }
|
|
469
|
-
);
|
|
446
|
+
tmuxUtils.sendToCC(values.session, values.text);
|
|
470
447
|
output(true, { session: values.session, sent: values.text });
|
|
471
448
|
} catch (e) {
|
|
472
|
-
output(false,
|
|
449
|
+
output(false, e.message);
|
|
473
450
|
}
|
|
474
451
|
return;
|
|
475
452
|
}
|
|
@@ -484,21 +461,12 @@ async function cmdCc(subAction, restArgs) {
|
|
|
484
461
|
strict: false,
|
|
485
462
|
});
|
|
486
463
|
if (!values.dir) output(false, '--dir required');
|
|
487
|
-
|
|
488
|
-
const sessionName = values.session || join(values.dir).split('/').pop();
|
|
489
|
-
// 检查 session 是否已存在
|
|
490
|
-
const existing = ccGetSessions();
|
|
491
|
-
if (existing.find(s => s.session === sessionName)) {
|
|
492
|
-
output(false, `session "${sessionName}" already exists`);
|
|
493
|
-
}
|
|
464
|
+
const sessionName = values.session || values.dir.split('/').pop();
|
|
494
465
|
try {
|
|
495
|
-
|
|
496
|
-
`tmux new-session -d -s "${sessionName}" -c "${values.dir}" 'claude --dangerously-skip-permissions'`,
|
|
497
|
-
{ timeout: 10000 }
|
|
498
|
-
);
|
|
466
|
+
tmuxUtils.startCC(values.dir, sessionName);
|
|
499
467
|
output(true, { session: sessionName, dir: values.dir, started: true });
|
|
500
468
|
} catch (e) {
|
|
501
|
-
output(false,
|
|
469
|
+
output(false, e.message);
|
|
502
470
|
}
|
|
503
471
|
return;
|
|
504
472
|
}
|
|
@@ -528,6 +496,7 @@ function parseDuration(str) {
|
|
|
528
496
|
if (action === 'group') return await cmdMsgGroup(rest);
|
|
529
497
|
output(false, `Unknown msg action: ${action}`);
|
|
530
498
|
}
|
|
499
|
+
if (domain === 'tell') return await cmdTell(argv.slice(1));
|
|
531
500
|
if (domain === 'contacts') return await cmdContacts(action, rest);
|
|
532
501
|
if (domain === 'guardian') return await cmdGuardian(action);
|
|
533
502
|
if (domain === 'wechat') return await cmdWechat(action, rest);
|
package/lib/guardian.js
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, openSyn
|
|
|
15
15
|
import { join, isAbsolute } from 'node:path';
|
|
16
16
|
import { execSync, spawn } from 'node:child_process';
|
|
17
17
|
import { SEAM_DIR, CREDENTIALS_PATH, SOCKET_PATH, LOGS_DIR, PID_PATH } from './paths.js';
|
|
18
|
+
import { writeEntry as registryWrite, removeEntry as registryRemove } from './registry.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* 解析 $TMUX 里的 socket path。返回可用于 `tmux -S <path>` 的绝对路径或 null。
|
|
@@ -136,16 +137,36 @@ export async function guardianRun() {
|
|
|
136
137
|
console.error(`Guardian already running (pid: ${existingPid}). Exiting.`);
|
|
137
138
|
process.exit(0);
|
|
138
139
|
}
|
|
139
|
-
// 写 PID(覆盖 guardianStart 写的 child.pid),退出时清理
|
|
140
|
-
writeFileSync(PID_PATH, String(process.pid));
|
|
141
|
-
const cleanPid = () => { try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {} };
|
|
142
|
-
process.on('exit', cleanPid);
|
|
143
|
-
process.on('SIGTERM', () => { cleanPid(); process.exit(0); });
|
|
144
|
-
process.on('SIGINT', () => { cleanPid(); process.exit(0); });
|
|
145
|
-
|
|
146
140
|
const credentials = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
|
|
147
141
|
const ccSession = process.env.SEAM_CC_SESSION || '';
|
|
148
142
|
const ccSocket = process.env.SEAM_CC_SOCKET || resolveTmuxSocketPath() || '';
|
|
143
|
+
|
|
144
|
+
// 写 PID(覆盖 guardianStart 写的 child.pid),退出时清理
|
|
145
|
+
writeFileSync(PID_PATH, String(process.pid));
|
|
146
|
+
|
|
147
|
+
// 写 cross-AI registry entry(~/.shared/seam-registry/<userId>.json)
|
|
148
|
+
try {
|
|
149
|
+
registryWrite({
|
|
150
|
+
userId: credentials.userId,
|
|
151
|
+
aliases: [credentials.name].filter(Boolean),
|
|
152
|
+
seam_home: SEAM_DIR,
|
|
153
|
+
tmux_session: ccSession,
|
|
154
|
+
tmux_socket: ccSocket,
|
|
155
|
+
socket: SOCKET_PATH,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
started_at: new Date().toISOString(),
|
|
158
|
+
});
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.error(`[guardian] registry write failed (non-fatal): ${e.message}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cleanup = () => {
|
|
164
|
+
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
165
|
+
try { registryRemove(credentials.userId); } catch {}
|
|
166
|
+
};
|
|
167
|
+
process.on('exit', cleanup);
|
|
168
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
169
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
149
170
|
const jsonlPath = join(LOGS_DIR, 'guardian.jsonl');
|
|
150
171
|
|
|
151
172
|
if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
|
|
@@ -219,35 +240,28 @@ export async function guardianRun() {
|
|
|
219
240
|
? '🔄 [Seam] 升级完成。Guardian 将在 10 秒后重启 CC 以加载新的 MCP 工具。你会在新对话里看到单个 seam 工具取代原来的多个工具。'
|
|
220
241
|
: '🔄 [Seam] 入网完成。Guardian 将在 10 秒后重启 CC 以加载 MCP 工具。重启后你会在新的对话里读到 IDENTITY.md。';
|
|
221
242
|
hub.inject(notice);
|
|
222
|
-
setTimeout(() => {
|
|
243
|
+
setTimeout(async () => {
|
|
223
244
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
);
|
|
234
|
-
guardianState.set('cc_restarted', new Date().toISOString());
|
|
235
|
-
if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
|
|
236
|
-
hub.logger('guardian').info('cc_restart_injected');
|
|
237
|
-
} catch (e) {
|
|
238
|
-
hub.logger('guardian').error('cc_restart_failed', e);
|
|
239
|
-
}
|
|
240
|
-
}, 3000);
|
|
245
|
+
const { restartCC } = require('./tmux-utils.cjs');
|
|
246
|
+
const ok = await restartCC(safeSession, { socketPath: ccSocket, continueSession: true });
|
|
247
|
+
if (ok) {
|
|
248
|
+
guardianState.set('cc_restarted', new Date().toISOString());
|
|
249
|
+
if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
|
|
250
|
+
hub.logger('guardian').info('cc_restart_injected');
|
|
251
|
+
} else {
|
|
252
|
+
hub.logger('guardian').error('cc_restart_failed', new Error('restartCC returned false'));
|
|
253
|
+
}
|
|
241
254
|
} catch (e) {
|
|
242
|
-
hub.logger('guardian').error('
|
|
255
|
+
hub.logger('guardian').error('cc_restart_failed', e);
|
|
243
256
|
}
|
|
244
|
-
}, 10000);
|
|
257
|
+
}, 10000);
|
|
245
258
|
}
|
|
246
259
|
|
|
247
|
-
// Keep alive + graceful shutdown (pid 文件清理)
|
|
260
|
+
// Keep alive + graceful shutdown (pid 文件清理 + registry 清理)
|
|
248
261
|
const shutdown = async (signal) => {
|
|
249
262
|
hub.logger('guardian').info('shutdown_signal', { signal });
|
|
250
263
|
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
264
|
+
try { registryRemove(credentials.userId); } catch {}
|
|
251
265
|
await stop();
|
|
252
266
|
process.exit(0);
|
|
253
267
|
};
|
package/lib/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'n
|
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
5
6
|
import { register } from './api.js';
|
|
6
7
|
import {
|
|
7
8
|
SEAM_DIR, CREDENTIALS_PATH, VERSION_PATH,
|
|
@@ -85,9 +86,12 @@ export async function init({ inviteCode, name, apiBase }) {
|
|
|
85
86
|
// Step 9: Write .mcp.json in current directory
|
|
86
87
|
writeMcpConfig(result);
|
|
87
88
|
|
|
88
|
-
// Step 10: Write .claude/settings.json (pre-authorize MCP)
|
|
89
|
+
// Step 10: Write .claude/settings.json (pre-authorize MCP + inject SEAM_HOME)
|
|
89
90
|
writeSettings();
|
|
90
91
|
|
|
92
|
+
// Step 10b: Register this SEAM_HOME so `upgrade-all` can find it
|
|
93
|
+
registerSeamHome();
|
|
94
|
+
|
|
91
95
|
// Step 11: Add @IDENTITY.md to CLAUDE.md
|
|
92
96
|
patchClaudeMd();
|
|
93
97
|
|
|
@@ -196,6 +200,10 @@ function writeSettings() {
|
|
|
196
200
|
|
|
197
201
|
settings.enableAllProjectMcpServers = true;
|
|
198
202
|
|
|
203
|
+
// host-tool: CC 子进程注入 SEAM_HOME,paths.js 第一级 fallback 命中
|
|
204
|
+
if (!settings.env) settings.env = {};
|
|
205
|
+
settings.env.SEAM_HOME = SEAM_DIR;
|
|
206
|
+
|
|
199
207
|
// SessionStart hook: CC 启动时自动拉起 guardian(如果还没跑)
|
|
200
208
|
if (!settings.hooks) settings.hooks = {};
|
|
201
209
|
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
@@ -218,7 +226,22 @@ function writeSettings() {
|
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
221
|
-
console.log(
|
|
229
|
+
console.log(` MCP pre-authorized + env.SEAM_HOME=${SEAM_DIR} + SessionStart hook wired.`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function registerSeamHome() {
|
|
233
|
+
const homesPath = join(homedir(), '.seam-homes');
|
|
234
|
+
let lines = [];
|
|
235
|
+
if (existsSync(homesPath)) {
|
|
236
|
+
lines = readFileSync(homesPath, 'utf8').split('\n').filter(Boolean);
|
|
237
|
+
}
|
|
238
|
+
if (!lines.includes(SEAM_DIR)) {
|
|
239
|
+
lines.push(SEAM_DIR);
|
|
240
|
+
writeFileSync(homesPath, lines.join('\n') + '\n');
|
|
241
|
+
console.log(` Registered to ${homesPath}`);
|
|
242
|
+
} else {
|
|
243
|
+
console.log(` Already registered in ${homesPath}`);
|
|
244
|
+
}
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
export function patchClaudeMd() {
|
package/lib/paths.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { join, resolve, dirname } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
2
4
|
|
|
3
|
-
export
|
|
5
|
+
export function resolveSeamHome() {
|
|
6
|
+
if (process.env.SEAM_HOME) return resolve(process.env.SEAM_HOME);
|
|
7
|
+
|
|
8
|
+
let dir = process.cwd();
|
|
9
|
+
while (true) {
|
|
10
|
+
const candidate = join(dir, '.seam');
|
|
11
|
+
if (existsSync(candidate)) return candidate;
|
|
12
|
+
const parent = dirname(dir);
|
|
13
|
+
if (parent === dir) break;
|
|
14
|
+
dir = parent;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return join(homedir(), '.seam');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SEAM_DIR = resolveSeamHome();
|
|
4
21
|
export const CREDENTIALS_PATH = join(SEAM_DIR, 'credentials.json');
|
|
5
22
|
export const CONFIG_PATH = join(SEAM_DIR, 'config.json');
|
|
6
23
|
export const VERSION_PATH = join(SEAM_DIR, 'version.json');
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-AI registry:~/.shared/seam-registry/<userId>.json
|
|
3
|
+
*
|
|
4
|
+
* Guardian 启动时写 entry,shutdown 时删 entry。`seam tell` 读 entries 查目标
|
|
5
|
+
* AI。死信检测在读时做:pid 已死的 entry 直接删除。
|
|
6
|
+
*
|
|
7
|
+
* 并发写:每条 entry 单独文件 + atomic rename(写 .tmp 再 rename)。
|
|
8
|
+
* 同 userId 双 guardian 不应出现(guardian 单例锁保证),出现则后写覆盖先写。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
const REGISTRY_DIR = join(homedir(), '.shared', 'seam-registry');
|
|
16
|
+
|
|
17
|
+
function ensureDir() {
|
|
18
|
+
if (!existsSync(REGISTRY_DIR)) {
|
|
19
|
+
mkdirSync(REGISTRY_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isAlive(pid) {
|
|
24
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function entryPath(userId) {
|
|
34
|
+
return join(REGISTRY_DIR, `${userId}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeEntry(entry) {
|
|
38
|
+
if (!entry?.userId) throw new Error('writeEntry: userId required');
|
|
39
|
+
ensureDir();
|
|
40
|
+
const target = entryPath(entry.userId);
|
|
41
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
42
|
+
writeFileSync(tmp, JSON.stringify(entry, null, 2));
|
|
43
|
+
renameSync(tmp, target);
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function removeEntry(userId) {
|
|
48
|
+
if (!userId) return;
|
|
49
|
+
const target = entryPath(userId);
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(target)) unlinkSync(target);
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 列出所有活 entry。死信(pid 已死)会被顺手删除。
|
|
57
|
+
* 顺便清掉残留 .tmp.* 文件(上次崩溃留下的)。
|
|
58
|
+
*/
|
|
59
|
+
export function readAll() {
|
|
60
|
+
if (!existsSync(REGISTRY_DIR)) return [];
|
|
61
|
+
let files;
|
|
62
|
+
try {
|
|
63
|
+
files = readdirSync(REGISTRY_DIR);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const alive = [];
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
const full = join(REGISTRY_DIR, f);
|
|
70
|
+
if (f.includes('.tmp.')) {
|
|
71
|
+
try { unlinkSync(full); } catch {}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!f.endsWith('.json')) continue;
|
|
75
|
+
let entry;
|
|
76
|
+
try {
|
|
77
|
+
entry = JSON.parse(readFileSync(full, 'utf8'));
|
|
78
|
+
} catch {
|
|
79
|
+
try { unlinkSync(full); } catch {}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!isAlive(entry.pid)) {
|
|
83
|
+
try { unlinkSync(full); } catch {}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
alive.push(entry);
|
|
87
|
+
}
|
|
88
|
+
return alive;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function findByAlias(needle) {
|
|
92
|
+
if (!needle) return null;
|
|
93
|
+
const entries = readAll();
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
if (e.userId === needle) return e;
|
|
96
|
+
if (Array.isArray(e.aliases) && e.aliases.includes(needle)) return e;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { REGISTRY_DIR };
|
package/lib/tell.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* seam tell:跨 AI 单条投递。
|
|
3
|
+
*
|
|
4
|
+
* 优先走 cross-AI registry —— 目标 AI 在本机 → 直接调 tmux sendToCC 注入;
|
|
5
|
+
* 不在或 socket 死 → fallback 走 IM 发到对方 userId。
|
|
6
|
+
*
|
|
7
|
+
* 入站 inject 加 from header:`💬 [本机 ← <from>] <text>`
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { findByAlias } from './registry.js';
|
|
13
|
+
import { CREDENTIALS_PATH } from './paths.js';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
function resolveSelfName() {
|
|
18
|
+
if (!existsSync(CREDENTIALS_PATH)) return null;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))?.name || null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatLocalInject(from, text) {
|
|
27
|
+
const tag = from ? `[本机 ← ${from}]` : '[本机]';
|
|
28
|
+
return `💬 ${tag} ${text}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {Promise<{ok:true, channel:'local'|'im', target:string} | {ok:false, error:string}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function tell({ to, from, text, guardianRequest }) {
|
|
35
|
+
if (!to) return { ok: false, error: '--to required' };
|
|
36
|
+
if (!text) return { ok: false, error: '--text required' };
|
|
37
|
+
|
|
38
|
+
const fromName = from || resolveSelfName() || '?';
|
|
39
|
+
|
|
40
|
+
const entry = findByAlias(to);
|
|
41
|
+
if (entry?.tmux_session) {
|
|
42
|
+
try {
|
|
43
|
+
const { sendToCC } = require('./tmux-utils.cjs');
|
|
44
|
+
const injectText = formatLocalInject(fromName, text);
|
|
45
|
+
sendToCC(entry.tmux_session, injectText, { socketPath: entry.tmux_socket || undefined });
|
|
46
|
+
return { ok: true, channel: 'local', target: entry.userId };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// 本机命中但 CC 不在线 / tmux session 死了 → 落 IM
|
|
49
|
+
if (!guardianRequest) {
|
|
50
|
+
return { ok: false, error: `local inject failed: ${e.message}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!guardianRequest) {
|
|
56
|
+
return { ok: false, error: `target "${to}" not in registry and no IM fallback wired` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const targetUserId = entry?.userId || to;
|
|
60
|
+
try {
|
|
61
|
+
const res = await guardianRequest({ action: 'send_im', to: targetUserId, text });
|
|
62
|
+
if (res?.ok === false) return { ok: false, error: res.error || 'IM fallback failed' };
|
|
63
|
+
return { ok: true, channel: 'im', target: targetUserId };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { ok: false, error: `IM fallback error: ${e.message}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-utils — 可靠的 tmux 交互。
|
|
3
|
+
*
|
|
4
|
+
* CC 用 raw terminal mode,tmux send-keys 需要特殊处理:
|
|
5
|
+
* 1. 文本用 -l (literal) 发,不解释特殊键
|
|
6
|
+
* 2. Enter 单独发
|
|
7
|
+
* 3. 发完验证是否被接收
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync } = require('node:child_process');
|
|
11
|
+
const { existsSync, readFileSync } = require('node:fs');
|
|
12
|
+
const { join } = require('node:path');
|
|
13
|
+
|
|
14
|
+
const EXEC_OPTS = { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
15
|
+
|
|
16
|
+
function tmuxCmd(socketPath) {
|
|
17
|
+
return socketPath ? `tmux -S "${socketPath}"` : 'tmux';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listSessions(socketPath) {
|
|
21
|
+
const tmux = tmuxCmd(socketPath);
|
|
22
|
+
let lines;
|
|
23
|
+
try {
|
|
24
|
+
lines = execSync(`${tmux} list-sessions -F "#{session_name}"`, EXEC_OPTS)
|
|
25
|
+
.trim().split('\n').filter(Boolean);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
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);
|
|
37
|
+
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
|
+
} catch {}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 给 tmux session 里的 CC 发用户消息。
|
|
54
|
+
* 用 -l (literal) 发文本 + 单独 Enter,保证 raw terminal mode 下不丢回车。
|
|
55
|
+
*/
|
|
56
|
+
function sendToCC(session, text, { socketPath, skipCheck = false } = {}) {
|
|
57
|
+
const tmux = tmuxCmd(socketPath);
|
|
58
|
+
if (!skipCheck && !isCCRunning(session, socketPath)) {
|
|
59
|
+
throw new Error(`session "${session}": CC not running`);
|
|
60
|
+
}
|
|
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);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 读 tmux session 的最后 N 行输出。
|
|
69
|
+
*/
|
|
70
|
+
function readOutput(session, lines = 20, socketPath) {
|
|
71
|
+
const tmux = tmuxCmd(socketPath);
|
|
72
|
+
return execSync(
|
|
73
|
+
`${tmux} capture-pane -t "${session}" -p | tail -${parseInt(lines) || 20}`,
|
|
74
|
+
EXEC_OPTS
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 在新 tmux session 里启动 CC。
|
|
80
|
+
*/
|
|
81
|
+
function startCC(dir, session, socketPath) {
|
|
82
|
+
const tmux = tmuxCmd(socketPath);
|
|
83
|
+
if (!existsSync(dir)) throw new Error(`dir not found: ${dir}`);
|
|
84
|
+
try {
|
|
85
|
+
execSync(`${tmux} has-session -t "${session}" 2>/dev/null`, EXEC_OPTS);
|
|
86
|
+
throw new Error(`session "${session}" already exists`);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e.message.includes('already exists')) throw e;
|
|
89
|
+
}
|
|
90
|
+
execSync(
|
|
91
|
+
`${tmux} new-session -d -s "${session}" -c "${dir}" 'claude --dangerously-skip-permissions'`,
|
|
92
|
+
{ ...EXEC_OPTS, timeout: 10000 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 给 CC 发 /exit 然后重启(带 --continue)。
|
|
98
|
+
* guardian 升级后自动重启 CC 用这个。
|
|
99
|
+
*/
|
|
100
|
+
function restartCC(session, { socketPath, continueSession = true } = {}) {
|
|
101
|
+
const tmux = tmuxCmd(socketPath);
|
|
102
|
+
// 发 /exit
|
|
103
|
+
execSync(`${tmux} send-keys -t "${session}" -l '/exit'`, EXEC_OPTS);
|
|
104
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
105
|
+
// 等 CC 退出
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
try {
|
|
109
|
+
const cmd = continueSession
|
|
110
|
+
? 'claude --dangerously-skip-permissions --continue'
|
|
111
|
+
: 'claude --dangerously-skip-permissions';
|
|
112
|
+
execSync(`${tmux} send-keys -t "${session}" -l ${JSON.stringify(cmd)}`, EXEC_OPTS);
|
|
113
|
+
execSync(`${tmux} send-keys -t "${session}" Enter`, EXEC_OPTS);
|
|
114
|
+
resolve(true);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
resolve(false);
|
|
117
|
+
}
|
|
118
|
+
}, 3000);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { listSessions, isCCRunning, sendToCC, readOutput, startCC, restartCC, tmuxCmd };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-tool 模式:对所有已注册的 SEAM_HOME 滚动升级 + 重启 guardian。
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 1. 全局升 npm 包(仅一次)
|
|
6
|
+
* 2. 读 ~/.seam-homes,遍历每个 SEAM_HOME
|
|
7
|
+
* 3. 每个 SEAM_HOME spawn 子进程 stop → start guardian(隔离 paths.js 缓存)
|
|
8
|
+
* 4. 清掉目录已不存在的 entry
|
|
9
|
+
*
|
|
10
|
+
* 注:本文件是 stage 1 的 registry 实现;stage 2 会替换为 ~/.shared/seam-registry/<userId>.json。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const CLI_PATH = join(dirname(__filename), '..', 'bin', 'cli.js');
|
|
21
|
+
|
|
22
|
+
export async function upgradeAll() {
|
|
23
|
+
console.log('Seam — host-tool upgrade-all\n');
|
|
24
|
+
|
|
25
|
+
console.log('1. npm install -g @seamnet/client@latest');
|
|
26
|
+
try {
|
|
27
|
+
execSync('npm install -g @seamnet/client@latest', { stdio: 'inherit' });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error(` npm install -g failed: ${e.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const homesPath = join(homedir(), '.seam-homes');
|
|
34
|
+
if (!existsSync(homesPath)) {
|
|
35
|
+
console.log('\n2. ~/.seam-homes 不存在——无已注册 AI,结束。');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const lines = readFileSync(homesPath, 'utf8').split('\n').filter(Boolean);
|
|
39
|
+
console.log(`\n2. ${lines.length} 个已注册 SEAM_HOME,逐个重启 guardian`);
|
|
40
|
+
|
|
41
|
+
const alive = [];
|
|
42
|
+
for (const home of lines) {
|
|
43
|
+
console.log(`\n → ${home}`);
|
|
44
|
+
if (!existsSync(home)) {
|
|
45
|
+
console.log(' 跳过(目录已不存在,从 registry 移除)');
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const env = { ...process.env, SEAM_HOME: home };
|
|
49
|
+
const stopRes = spawnSync('node', [CLI_PATH, 'stop'], { env, stdio: 'inherit' });
|
|
50
|
+
if (stopRes.status !== 0) {
|
|
51
|
+
console.log(' stop 非零退出(guardian 可能未运行,继续)');
|
|
52
|
+
}
|
|
53
|
+
const startRes = spawnSync('node', [CLI_PATH, 'guardian', 'start'], { env, stdio: 'inherit' });
|
|
54
|
+
if (startRes.status !== 0) {
|
|
55
|
+
console.error(` start 失败 (exit ${startRes.status})`);
|
|
56
|
+
} else {
|
|
57
|
+
alive.push(home);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (alive.length !== lines.length) {
|
|
62
|
+
writeFileSync(homesPath, alive.length ? alive.join('\n') + '\n' : '');
|
|
63
|
+
console.log(`\n registry 清理:${lines.length} → ${alive.length}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(`\nDone. ${alive.length} 个 AI guardian 已重启。`);
|
|
67
|
+
}
|