@seamnet/client 0.12.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.
package/lib/errors.cjs ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Seam Error — 统一错误对象。
3
+ *
4
+ * 设计:
5
+ * - 错误都有 code(机器可识别)、message(人可读)、hint(修复建议)、docs(排查文档锚点)
6
+ * - toPublic() 用于 MCP 响应——不含 stack、cause
7
+ * - toLog() 用于 JSONL 日志——含 stack、cause
8
+ */
9
+
10
+ class SeamError extends Error {
11
+ constructor({ code, message, hint, docs, cause, reqId } = {}) {
12
+ super(message || code || 'unknown error');
13
+ this.name = 'SeamError';
14
+ this.code = code || 'UNKNOWN';
15
+ this.hint = hint;
16
+ this.docs = docs;
17
+ this.cause = cause;
18
+ this.reqId = reqId;
19
+ }
20
+
21
+ toPublic() {
22
+ const out = { code: this.code, message: this.message };
23
+ if (this.hint) out.hint = this.hint;
24
+ if (this.docs) out.docs = this.docs;
25
+ if (this.reqId) out.req_id = this.reqId;
26
+ return out;
27
+ }
28
+
29
+ toLog() {
30
+ return {
31
+ code: this.code,
32
+ message: this.message,
33
+ hint: this.hint,
34
+ docs: this.docs,
35
+ req_id: this.reqId,
36
+ stack: this.stack,
37
+ cause: this.cause?.message || (typeof this.cause === 'string' ? this.cause : undefined),
38
+ };
39
+ }
40
+ }
41
+
42
+ function seamError(opts) {
43
+ return new SeamError(opts);
44
+ }
45
+
46
+ /**
47
+ * 包装任意 error 成 SeamError。
48
+ * - 已经是 SeamError:不包装,补全缺失的 reqId
49
+ * - 其他:创建新 SeamError,cause 指向原 error
50
+ */
51
+ function wrap(err, { code, hint, docs, reqId } = {}) {
52
+ if (err instanceof SeamError) {
53
+ if (reqId && !err.reqId) err.reqId = reqId;
54
+ return err;
55
+ }
56
+ return new SeamError({
57
+ code: code || 'UNKNOWN',
58
+ message: err?.message || String(err),
59
+ hint,
60
+ docs,
61
+ reqId,
62
+ cause: err,
63
+ });
64
+ }
65
+
66
+ module.exports = { SeamError, seamError, wrap };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Guardian Core — 两阶段生命周期 + socket server。
3
+ *
4
+ * 设计:
5
+ * 1. 注册阶段:所有插件 register 到 hub,actions 建立唯一性检查
6
+ * 2. Init 阶段:依次 init 每个插件,错误经 hub.run 隔离
7
+ * 3. Serve 阶段:socket server 按 action 路由到插件
8
+ * 4. Stop 阶段:倒序 destroy,每个有 5s 超时
9
+ */
10
+
11
+ const net = require('node:net');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const crypto = require('node:crypto');
15
+ const { createHub } = require('./hub.cjs');
16
+ const { createLogger } = require('./services/logger/index.cjs');
17
+ const { createStateService } = require('./services/state/index.cjs');
18
+ const { createEventBus } = require('./services/event-bus/index.cjs');
19
+ const { createMessageBuffer } = require('./services/message-buffer/index.cjs');
20
+ const { createInboxService } = require('./services/inbox/index.cjs');
21
+ const { wrap } = require('./errors.cjs');
22
+
23
+ /**
24
+ * 启动 guardian core。
25
+ *
26
+ * @param {Object} opts
27
+ * @param {string} opts.seamDir - .seam/ 路径
28
+ * @param {string} opts.socketPath - Unix socket 路径
29
+ * @param {string} opts.logPath - JSONL 日志路径
30
+ * @param {string} [opts.ccSession] - CC tmux 会话名(可选)
31
+ * @param {Object} [opts.credentials] - IM 凭据
32
+ * @param {Array} [opts.plugins] - 符合 Plugin 契约的插件数组
33
+ * @returns {Promise<{hub, server, stop}>}
34
+ */
35
+ async function startGuardianCore({
36
+ seamDir,
37
+ socketPath,
38
+ logPath,
39
+ ccSession = '',
40
+ ccSocket = '',
41
+ credentials = {},
42
+ plugins = [],
43
+ }) {
44
+ const logger = createLogger({ logPath });
45
+ const coreLog = logger.child({ plugin: 'guardian' });
46
+ const hub = createHub({ seamDir, ccSession, ccSocket, credentials, logger });
47
+
48
+ // === 注册内置 Services ===
49
+ const stateService = createStateService({
50
+ stateFile: path.join(seamDir, 'state.json'),
51
+ });
52
+ const eventBus = createEventBus();
53
+ const messageBuffer = createMessageBuffer();
54
+ const inboxService = createInboxService();
55
+ hub.registerService(stateService);
56
+ hub.registerService(eventBus);
57
+ hub.registerService(messageBuffer);
58
+ hub.registerService(inboxService);
59
+
60
+ for (const svc of hub.listServices()) {
61
+ const [err] = await hub.run(svc.name, () => svc.init(hub));
62
+ if (err) {
63
+ coreLog.error('service_init_failed', err.toLog ? err.toLog() : err, {
64
+ service: svc.name,
65
+ });
66
+ } else {
67
+ coreLog.info('service_init_success', { service: svc.name });
68
+ }
69
+ }
70
+
71
+ // === 阶段 1: 注册 ===
72
+ const actionMap = new Map();
73
+ for (const plugin of plugins) {
74
+ hub.register(plugin);
75
+ for (const action of plugin.actions || []) {
76
+ if (actionMap.has(action)) {
77
+ throw new Error(
78
+ `Duplicate action: ${action} (already registered by ${actionMap.get(action).name}, now ${plugin.name})`
79
+ );
80
+ }
81
+ actionMap.set(action, plugin);
82
+ }
83
+ }
84
+ coreLog.info('plugins_registered', {
85
+ count: plugins.length,
86
+ names: plugins.map((p) => p.name),
87
+ actions: [...actionMap.keys()],
88
+ });
89
+
90
+ // === 阶段 2: init(错误隔离)===
91
+ for (const plugin of plugins) {
92
+ const [err] = await hub.run(plugin.name, () => plugin.init(hub));
93
+ if (err) {
94
+ coreLog.error('plugin_init_failed', err.toLog ? err.toLog() : err, {
95
+ plugin: plugin.name,
96
+ });
97
+ } else {
98
+ coreLog.info('plugin_init_success', { plugin: plugin.name });
99
+ }
100
+ }
101
+
102
+ // === 阶段 3: socket server ===
103
+ if (fs.existsSync(socketPath)) {
104
+ fs.unlinkSync(socketPath);
105
+ }
106
+
107
+ const server = net.createServer((conn) => {
108
+ let buffer = '';
109
+ conn.on('data', async (chunk) => {
110
+ buffer += chunk;
111
+ let req;
112
+ try {
113
+ req = JSON.parse(buffer);
114
+ } catch {
115
+ return; // incomplete, wait for more
116
+ }
117
+
118
+ const reqId = req.req_id || crypto.randomUUID();
119
+ const reqLog = logger.child({ plugin: 'guardian', reqId });
120
+ reqLog.info('request', { action: req.action });
121
+
122
+ const plugin = actionMap.get(req.action);
123
+ if (!plugin) {
124
+ reqLog.warn('unknown_action', { action: req.action });
125
+ conn.end(
126
+ JSON.stringify({
127
+ error: {
128
+ code: 'UNKNOWN_ACTION',
129
+ message: `No plugin handles action: ${req.action}`,
130
+ req_id: reqId,
131
+ },
132
+ })
133
+ );
134
+ return;
135
+ }
136
+
137
+ const [err, result] = await hub.run(plugin.name, () =>
138
+ plugin.handleRequest({ ...req, req_id: reqId })
139
+ );
140
+ if (err) {
141
+ if (!err.reqId) err.reqId = reqId;
142
+ reqLog.error('handler_failed', err.toLog ? err.toLog() : err, {
143
+ action: req.action,
144
+ plugin: plugin.name,
145
+ });
146
+ const publicErr = err.toPublic ? err.toPublic() : { code: 'UNKNOWN', message: String(err), req_id: reqId };
147
+ conn.end(JSON.stringify({ error: publicErr }));
148
+ } else {
149
+ reqLog.info('request_done', { action: req.action, plugin: plugin.name });
150
+ conn.end(JSON.stringify({ ...(result || {}), req_id: reqId }));
151
+ }
152
+ });
153
+ conn.on('error', (e) => {
154
+ coreLog.error('conn_error', e);
155
+ });
156
+ });
157
+
158
+ await new Promise((resolve, reject) => {
159
+ server.once('error', reject);
160
+ server.listen(socketPath, () => {
161
+ server.off('error', reject);
162
+ resolve();
163
+ });
164
+ });
165
+
166
+ // Restrict socket to owner only (单机多用户防护)
167
+ try {
168
+ fs.chmodSync(socketPath, 0o600);
169
+ } catch (e) {
170
+ coreLog.warn('chmod_failed', { message: e.message });
171
+ }
172
+
173
+ coreLog.info('serving', { socketPath });
174
+
175
+ // === Stop function ===
176
+ async function stop() {
177
+ coreLog.info('stopping');
178
+ return new Promise(async (resolve) => {
179
+ server.close(async () => {
180
+ // destroy plugins first (they may depend on services)
181
+ for (const plugin of [...plugins].reverse()) {
182
+ await hub.run(plugin.name, () => plugin.destroy(), { timeout: 5000 });
183
+ }
184
+ // then destroy services
185
+ for (const svc of [...hub.listServices()].reverse()) {
186
+ await hub.run(svc.name, () => svc.destroy(), { timeout: 5000 });
187
+ }
188
+ try {
189
+ if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
190
+ } catch {}
191
+ coreLog.info('stopped');
192
+ resolve();
193
+ });
194
+ });
195
+ }
196
+
197
+ return { hub, server, actionMap, stop, logger };
198
+ }
199
+
200
+ module.exports = { startGuardianCore };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Seam Guardian — 后台长驻进程。
3
+ *
4
+ * guardianStart: spawn detached node 子进程跑 guardianRun(在当前 tmux 会话后台)
5
+ * guardianRun: 加载插件 → 调 startGuardianCore → 保留业务逻辑(greeting / CC 重启)
6
+ * guardianStop: 根据 .seam/guardian.pid 发 SIGTERM
7
+ *
8
+ * 设计:
9
+ * - 不再 fork 独立 tmux session;guardian 作为**当前 tmux session 的后台进程**运行
10
+ * - auto-restart CC 直接 send-keys 到当前 session 的前台 pane(就是 CC)
11
+ * - 生命期:绑定当前 tmux 会话(session 关掉 guardian 也挂,这是预期的)
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, readlinkSync } from 'node:fs';
15
+ import { join, isAbsolute } from 'node:path';
16
+ import { execSync, spawn } from 'node:child_process';
17
+ import { SEAM_DIR, CREDENTIALS_PATH, SOCKET_PATH, LOGS_DIR, PID_PATH } from './paths.js';
18
+
19
+ /**
20
+ * 解析 $TMUX 里的 socket path。返回可用于 `tmux -S <path>` 的绝对路径或 null。
21
+ *
22
+ * $TMUX 格式:`<socket_path>,<tmux_pid>,<window_index>`
23
+ * - 绝对路径(如 `/tmp/tmux-1003/default`)直接用
24
+ * - 相对名(如 `my`)通过 /proc/<pid>/cwd 拼成绝对路径
25
+ */
26
+ export function resolveTmuxSocketPath(tmuxEnv = process.env.TMUX) {
27
+ if (!tmuxEnv) return null;
28
+ const [rawPath, pidStr] = tmuxEnv.split(',');
29
+ if (!rawPath) return null;
30
+ if (isAbsolute(rawPath)) return rawPath;
31
+ if (pidStr) {
32
+ try {
33
+ const cwd = readlinkSync(`/proc/${pidStr}/cwd`);
34
+ return join(cwd, rawPath);
35
+ } catch {}
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function tmuxCmd(socketPath) {
41
+ return socketPath ? `tmux -S ${JSON.stringify(socketPath)}` : 'tmux';
42
+ }
43
+
44
+ function isPidAlive(pid) {
45
+ if (!Number.isInteger(pid) || pid <= 0) return false;
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function readGuardianPid() {
55
+ if (!existsSync(PID_PATH)) return null;
56
+ try {
57
+ const pid = parseInt(readFileSync(PID_PATH, 'utf8').trim(), 10);
58
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function isGuardianRunning() {
65
+ const pid = readGuardianPid();
66
+ return pid !== null && isPidAlive(pid);
67
+ }
68
+
69
+ export async function guardianStart() {
70
+ if (!existsSync(CREDENTIALS_PATH)) {
71
+ console.error('No credentials found. Run: npx seam-client init first.');
72
+ process.exit(1);
73
+ }
74
+
75
+ if (isGuardianRunning()) {
76
+ const pid = readGuardianPid();
77
+ console.log(`Guardian already running (pid: ${pid})`);
78
+ return;
79
+ }
80
+
81
+ // 清理 stale pid 文件
82
+ if (existsSync(PID_PATH)) {
83
+ try { unlinkSync(PID_PATH); } catch {}
84
+ }
85
+
86
+ if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
87
+
88
+ const socketPath = resolveTmuxSocketPath();
89
+ const tmux = tmuxCmd(socketPath);
90
+
91
+ let ccSession = '';
92
+ try {
93
+ ccSession = execSync(`${tmux} display-message -p '#S'`, { encoding: 'utf8' }).trim();
94
+ } catch {}
95
+
96
+ const cwd = process.cwd();
97
+ const localCli = join(cwd, 'node_modules', '@seamnet', 'client', 'bin', 'cli.js');
98
+ const logPath = join(LOGS_DIR, 'guardian.log');
99
+ const logFd = openSync(logPath, 'a');
100
+
101
+ const child = spawn(process.execPath, [localCli, 'guardian', 'run'], {
102
+ cwd,
103
+ detached: true,
104
+ stdio: ['ignore', logFd, logFd],
105
+ env: {
106
+ ...process.env,
107
+ SEAM_CC_SESSION: ccSession,
108
+ SEAM_CC_SOCKET: socketPath || '',
109
+ },
110
+ });
111
+
112
+ child.unref();
113
+
114
+ writeFileSync(PID_PATH, String(child.pid));
115
+
116
+ console.log(`Guardian started (pid: ${child.pid}, background in current tmux session)`);
117
+ console.log(` Logs: ${logPath}`);
118
+ console.log(` JSONL: ${join(LOGS_DIR, 'guardian.jsonl')}`);
119
+ if (socketPath) {
120
+ console.log(` Socket: ${socketPath}`);
121
+ }
122
+ if (ccSession) {
123
+ console.log(` CC will auto-restart when guardian is ready (session: ${ccSession}).`);
124
+ }
125
+ }
126
+
127
+ export async function guardianRun() {
128
+ if (!existsSync(CREDENTIALS_PATH)) {
129
+ console.error('No credentials found.');
130
+ process.exit(1);
131
+ }
132
+
133
+ const credentials = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
134
+ const ccSession = process.env.SEAM_CC_SESSION || '';
135
+ const ccSocket = process.env.SEAM_CC_SOCKET || resolveTmuxSocketPath() || '';
136
+ const jsonlPath = join(LOGS_DIR, 'guardian.jsonl');
137
+
138
+ if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
139
+
140
+ // Refresh CHANNEL_RULES.md + CLAUDE.md references from latest template
141
+ try {
142
+ const { syncChannelRules, patchClaudeMd } = await import('./init.js');
143
+ const rulesPath = syncChannelRules();
144
+ patchClaudeMd();
145
+ console.log(`[guardian] CHANNEL_RULES refreshed: ${rulesPath}`);
146
+ } catch (e) {
147
+ console.error(`[guardian] CHANNEL_RULES sync failed: ${e.message}`);
148
+ }
149
+
150
+ const { createRequire } = await import('node:module');
151
+ const require = createRequire(import.meta.url);
152
+ const { startGuardianCore } = require('./guardian-core.cjs');
153
+
154
+ // 插件加载:从 .seam/config.json 读 plugins 列表,缺省 [im, wechat]
155
+ // 插件工厂约定:export createXxxPlugin(XxxPlugin 是 PascalCase 插件名)
156
+ const configPath = join(SEAM_DIR, 'config.json');
157
+ let pluginNames = ['im', 'wechat', 'scheduler'];
158
+ if (existsSync(configPath)) {
159
+ try {
160
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
161
+ if (Array.isArray(cfg?.plugins) && cfg.plugins.length) {
162
+ pluginNames = cfg.plugins;
163
+ }
164
+ } catch (e) {
165
+ console.error(`[guardian] bad .seam/config.json, fall back to default: ${e.message}`);
166
+ }
167
+ }
168
+ const plugins = pluginNames.map((name) => {
169
+ const mod = require(`./plugins/${name}/index.cjs`);
170
+ const factoryKey = `create${name.charAt(0).toUpperCase()}${name.slice(1)}Plugin`;
171
+ const factory = mod[factoryKey];
172
+ if (typeof factory !== 'function') {
173
+ throw new Error(`plugin "${name}" missing ${factoryKey} export`);
174
+ }
175
+ return factory();
176
+ });
177
+
178
+ console.log(`[guardian] Starting for ${credentials.userId}...`);
179
+
180
+ const { hub, stop } = await startGuardianCore({
181
+ seamDir: SEAM_DIR,
182
+ socketPath: SOCKET_PATH,
183
+ logPath: jsonlPath,
184
+ ccSession,
185
+ ccSocket,
186
+ credentials,
187
+ plugins,
188
+ });
189
+
190
+ const guardianState = hub.service('state').scope('guardian');
191
+
192
+ // 自动重启 CC 加载 MCP
193
+ // - 首次入网:cc_restarted 未设置
194
+ // - 升级后:pending_upgrade_restart 标记(upgrade 命令写入)
195
+ const isFirstStart = !guardianState.has('cc_restarted');
196
+ const isUpgradeRestart = guardianState.has('pending_upgrade_restart');
197
+ if (ccSession && (isFirstStart || isUpgradeRestart)) {
198
+ const safeSession = ccSession.replace(/[^a-zA-Z0-9_-]/g, '_');
199
+ const tmux = tmuxCmd(ccSocket);
200
+ hub.logger('guardian').info('auto_restart_cc_scheduled', {
201
+ session: safeSession,
202
+ socket: ccSocket || 'default',
203
+ reason: isUpgradeRestart ? 'upgrade' : 'first_start',
204
+ });
205
+ const notice = isUpgradeRestart
206
+ ? '🔄 [Seam] 升级完成。Guardian 将在 10 秒后重启 CC 以加载新的 MCP 工具。你会在新对话里看到单个 seam 工具取代原来的多个工具。'
207
+ : '🔄 [Seam] 入网完成。Guardian 将在 10 秒后重启 CC 以加载 MCP 工具。重启后你会在新的对话里读到 IDENTITY.md。';
208
+ hub.inject(notice);
209
+ setTimeout(() => {
210
+ try {
211
+ execSync(`${tmux} send-keys -t ${safeSession} '/exit' Enter`, {
212
+ stdio: 'ignore',
213
+ timeout: 5000,
214
+ });
215
+ setTimeout(() => {
216
+ try {
217
+ execSync(
218
+ `${tmux} send-keys -t ${safeSession} 'claude --dangerously-skip-permissions' Enter`,
219
+ { stdio: 'ignore', timeout: 5000 }
220
+ );
221
+ guardianState.set('cc_restarted', new Date().toISOString());
222
+ if (isUpgradeRestart) guardianState.delete('pending_upgrade_restart');
223
+ hub.logger('guardian').info('cc_restart_injected');
224
+ } catch (e) {
225
+ hub.logger('guardian').error('cc_restart_failed', e);
226
+ }
227
+ }, 3000);
228
+ } catch (e) {
229
+ hub.logger('guardian').error('cc_exit_failed', e);
230
+ }
231
+ }, 10000); // 3s → 10s 给 AI 读到通知的时间
232
+ }
233
+
234
+ // Keep alive + graceful shutdown (pid 文件清理)
235
+ const shutdown = async (signal) => {
236
+ hub.logger('guardian').info('shutdown_signal', { signal });
237
+ try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
238
+ await stop();
239
+ process.exit(0);
240
+ };
241
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
242
+ process.on('SIGINT', () => shutdown('SIGINT'));
243
+ }
244
+
245
+ export async function guardianStop() {
246
+ const pid = readGuardianPid();
247
+ if (!pid || !isPidAlive(pid)) {
248
+ console.log('Guardian is not running.');
249
+ if (existsSync(PID_PATH)) {
250
+ try { unlinkSync(PID_PATH); } catch {}
251
+ }
252
+ return;
253
+ }
254
+ try {
255
+ process.kill(pid, 'SIGTERM');
256
+ console.log(`Guardian stopped (pid: ${pid})`);
257
+ } catch (e) {
258
+ console.error(`Failed to stop guardian (pid: ${pid}): ${e.message}`);
259
+ }
260
+ try { unlinkSync(PID_PATH); } catch {}
261
+ }
package/lib/hub.cjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Seam Hub — Guardian 提供给插件的上下文对象。
3
+ *
4
+ * 设计:
5
+ * - 插件通过 Hub 访问环境(seamDir, ccSession, credentials)
6
+ * - 插件通过 hub.get(name) 调用其他插件(避免循环依赖)
7
+ * - 插件方法统一经 hub.run 调用——错误隔离,一个插件崩不拖垮其他
8
+ * - 插件日志统一 hub.logger(name)——自动带 plugin 字段
9
+ */
10
+
11
+ const { execFileSync } = require('node:child_process');
12
+ const { wrap } = require('./errors.cjs');
13
+
14
+ function createHub({ seamDir, ccSession, ccSocket, credentials, logger }) {
15
+ const plugins = new Map();
16
+ const services = new Map();
17
+ const rootLogger = logger;
18
+ // 预置 tmux 参数:有 socket 就加 -S <path>,没有就走默认 socket
19
+ const tmuxBaseArgs = ccSocket ? ['-S', ccSocket] : [];
20
+
21
+ function register(plugin) {
22
+ if (!plugin || !plugin.name) {
23
+ throw new Error('Plugin must have a name');
24
+ }
25
+ if (plugins.has(plugin.name)) {
26
+ throw new Error(`Plugin already registered: ${plugin.name}`);
27
+ }
28
+ plugins.set(plugin.name, plugin);
29
+ }
30
+
31
+ function get(name) {
32
+ return plugins.get(name);
33
+ }
34
+
35
+ function list() {
36
+ return [...plugins.values()];
37
+ }
38
+
39
+ // === Service registry ===
40
+ // Services 是 Guardian 内部能力(scheduler/watcher/state/...),
41
+ // 只对插件可见,不对 MCP 暴露。
42
+ function registerService(service) {
43
+ if (!service || !service.name) {
44
+ throw new Error('Service must have a name');
45
+ }
46
+ if (services.has(service.name)) {
47
+ throw new Error(`Service already registered: ${service.name}`);
48
+ }
49
+ services.set(service.name, service);
50
+ }
51
+
52
+ function service(name) {
53
+ return services.get(name);
54
+ }
55
+
56
+ function listServices() {
57
+ return [...services.values()];
58
+ }
59
+
60
+ function inject(text) {
61
+ if (!ccSession) return false;
62
+ try {
63
+ const sanitized = String(text).replace(/[\x00-\x1f]/g, ' ').slice(0, 2000);
64
+ execFileSync('tmux', [...tmuxBaseArgs, 'send-keys', '-t', ccSession, sanitized], {
65
+ stdio: 'ignore',
66
+ timeout: 5000,
67
+ });
68
+ execFileSync('tmux', [...tmuxBaseArgs, 'send-keys', '-t', ccSession, 'Enter'], {
69
+ stdio: 'ignore',
70
+ timeout: 5000,
71
+ });
72
+ return true;
73
+ } catch (e) {
74
+ rootLogger.error('inject_failed', e, { textLength: String(text).length });
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function loggerFor(name) {
80
+ return rootLogger.child({ plugin: name });
81
+ }
82
+
83
+ /**
84
+ * 错误隔离执行 fn。返回 [err, result]。
85
+ * - fn 正常完成:返回 [null, result]
86
+ * - fn 抛异常:记日志,返回 [wrappedError, null]
87
+ * - 超时:返回超时错误
88
+ */
89
+ async function run(pluginName, fn, { timeout } = {}) {
90
+ const log = loggerFor(pluginName);
91
+ try {
92
+ let promise = Promise.resolve().then(() => fn());
93
+ if (timeout && timeout > 0) {
94
+ promise = Promise.race([
95
+ promise,
96
+ new Promise((_, reject) =>
97
+ setTimeout(
98
+ () =>
99
+ reject(
100
+ new Error(`plugin ${pluginName} exceeded timeout ${timeout}ms`)
101
+ ),
102
+ timeout
103
+ )
104
+ ),
105
+ ]);
106
+ }
107
+ const result = await promise;
108
+ return [null, result];
109
+ } catch (err) {
110
+ const wrapped = wrap(err, { code: err?.code });
111
+ log.error('plugin_call_failed', wrapped.toLog(), { pluginName });
112
+ return [wrapped, null];
113
+ }
114
+ }
115
+
116
+ return {
117
+ // read-only context
118
+ seamDir,
119
+ ccSession,
120
+ ccSocket,
121
+ credentials,
122
+
123
+ // plugin registry
124
+ register,
125
+ get,
126
+ list,
127
+
128
+ // service registry
129
+ registerService,
130
+ service,
131
+ listServices,
132
+
133
+ // operations (shortcuts to services)
134
+ inject,
135
+ logger: loggerFor,
136
+ run,
137
+ };
138
+ }
139
+
140
+ module.exports = { createHub };