lark-remote 0.2.2 → 0.3.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/CHANGELOG.md +16 -0
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/binder.d.ts +9 -16
- package/dist/binder.js +14 -40
- package/dist/clone.d.ts +139 -0
- package/dist/clone.js +415 -0
- package/dist/config/codex-config.js +79 -24
- package/dist/config/kimi-config.js +16 -5
- package/dist/connector/index.d.ts +19 -0
- package/dist/connector/index.js +57 -33
- package/dist/index.js +36 -13
- package/dist/restart.d.ts +17 -0
- package/dist/restart.js +38 -19
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.js +28 -0
- package/package.json +2 -2
package/dist/clone.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { registerApp } from '@larksuite/channel';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { getLogger } from './logger/index.js';
|
|
8
|
+
import { renderQrImage } from './config/qr.js';
|
|
9
|
+
import { silentlyUnlink } from './common/fs.js';
|
|
10
|
+
import { atomicWrite, atomicWriteJson } from './persistence/atomic-write.js';
|
|
11
|
+
import { spawnDetachedBridge } from './restart.js';
|
|
12
|
+
/** 退出命令(不区分大小写)。 */
|
|
13
|
+
const EXIT_COMMANDS = new Set(['/q']);
|
|
14
|
+
/** 重新生成二维码的关键词(精确匹配,避免误伤普通聊天)。 */
|
|
15
|
+
const RESEND_COMMANDS = new Set(['重发', '重发二维码', '重新生成', '重新生成二维码', '重新发送']);
|
|
16
|
+
/** win32 保留设备名(不区分大小写),用作分身目录名会在 mkdir 时失败。 */
|
|
17
|
+
const WIN_RESERVED_NAMES = new Set([
|
|
18
|
+
'con',
|
|
19
|
+
'prn',
|
|
20
|
+
'aux',
|
|
21
|
+
'nul',
|
|
22
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
23
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* 校验用户指定的分身名字:路径分隔符、以 . 开头、尾点/尾空格(win32 会静默
|
|
27
|
+
* 吞掉)、win32 保留设备名一律拒绝。
|
|
28
|
+
*/
|
|
29
|
+
export function isValidCloneName(name) {
|
|
30
|
+
if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.'))
|
|
31
|
+
return false;
|
|
32
|
+
if (/[\s.]$/.test(name))
|
|
33
|
+
return false;
|
|
34
|
+
if (WIN_RESERVED_NAMES.has(name.toLowerCase()))
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** 随机后缀:2 位数字 + 2 位小写字母/数字,如 `37ab`。 */
|
|
39
|
+
export function generateCloneSuffix() {
|
|
40
|
+
const digits = String(crypto.randomInt(0, 100)).padStart(2, '0');
|
|
41
|
+
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
42
|
+
let chars = '';
|
|
43
|
+
for (let i = 0; i < 2; i++)
|
|
44
|
+
chars += charset[crypto.randomInt(0, charset.length)];
|
|
45
|
+
return digits + chars;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 默认新实例拉起:同入口 + 同参数,仅替换 --config-dir(detached,返回 pid)。
|
|
49
|
+
* spawn 骨架(log fd / windowsHide / error 兜底)单源于 restart.ts。
|
|
50
|
+
*/
|
|
51
|
+
export function spawnDetachedCloneBridge(targetDir) {
|
|
52
|
+
const args = [];
|
|
53
|
+
const argv = process.argv.slice(2);
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
if (argv[i] === '--config-dir') {
|
|
56
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
args.push(argv[i]);
|
|
61
|
+
}
|
|
62
|
+
args.push('--config-dir', targetDir);
|
|
63
|
+
const entry = process.argv[1];
|
|
64
|
+
if (!entry)
|
|
65
|
+
return undefined;
|
|
66
|
+
const pid = spawnDetachedBridge([entry, ...args], path.join(targetDir, 'logs', 'spawn-child.log'));
|
|
67
|
+
return pid ?? undefined;
|
|
68
|
+
}
|
|
69
|
+
export class CloneSession {
|
|
70
|
+
state = 'idle';
|
|
71
|
+
targetDir;
|
|
72
|
+
abortController;
|
|
73
|
+
connector;
|
|
74
|
+
configPath;
|
|
75
|
+
configDir;
|
|
76
|
+
registerAppFn;
|
|
77
|
+
generateSuffixFn;
|
|
78
|
+
spawnNewInstanceFn;
|
|
79
|
+
constructor(opts) {
|
|
80
|
+
this.connector = opts.connector;
|
|
81
|
+
this.configPath = opts.configPath;
|
|
82
|
+
this.configDir = opts.configDir;
|
|
83
|
+
this.registerAppFn = opts.registerAppFn ?? registerApp;
|
|
84
|
+
this.generateSuffixFn = opts.generateSuffix ?? generateCloneSuffix;
|
|
85
|
+
this.spawnNewInstanceFn = opts.spawnNewInstance ?? spawnDetachedCloneBridge;
|
|
86
|
+
}
|
|
87
|
+
/** 当前状态(测试断言用)。 */
|
|
88
|
+
get currentState() {
|
|
89
|
+
return this.state;
|
|
90
|
+
}
|
|
91
|
+
isActive() {
|
|
92
|
+
return this.state !== 'idle';
|
|
93
|
+
}
|
|
94
|
+
/** 已确定但尚未写入的分身配置目录(测试断言用)。 */
|
|
95
|
+
get pendingTargetDir() {
|
|
96
|
+
return this.targetDir;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 进入创建分身流程:确定新配置目录 → 发二维码 → 等扫码。
|
|
100
|
+
* 目录已存在 / 名字非法时不进入流程(保持 idle),仅回错误说明。
|
|
101
|
+
*/
|
|
102
|
+
async start(nameArg, ctx) {
|
|
103
|
+
if (this.isActive()) {
|
|
104
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: '已在创建分身流程中,请先完成或输入 /Q 退出后再重新发起' }, { replyTo: ctx.messageId });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let suffix;
|
|
108
|
+
if (nameArg !== undefined && nameArg.length > 0) {
|
|
109
|
+
const name = nameArg.trim();
|
|
110
|
+
if (!isValidCloneName(name)) {
|
|
111
|
+
await this.connector.sendWithRetry(ctx.chatId, {
|
|
112
|
+
text: `⚠️ 无效的名字:${nameArg}\n` +
|
|
113
|
+
'名字不能包含路径分隔符、不能以 . 开头、不能以点或空格结尾,' +
|
|
114
|
+
'也不能是 Windows 保留设备名(con/nul/com1 等)\n' +
|
|
115
|
+
'用法:/clone <名字>(省略则随机生成)',
|
|
116
|
+
}, { replyTo: ctx.messageId });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
suffix = name;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// 随机后缀:先检查目标目录不存在再落定(存在则重新生成,最多 50 次)。
|
|
123
|
+
suffix = this.generateSuffixFn();
|
|
124
|
+
for (let i = 0; i < 50 && fs.existsSync(this.dirFor(suffix)); i++) {
|
|
125
|
+
suffix = this.generateSuffixFn();
|
|
126
|
+
}
|
|
127
|
+
if (fs.existsSync(this.dirFor(suffix))) {
|
|
128
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: '⚠️ 无法确定分身目录(随机名连续冲突),请稍后重试或用 /clone <名字> 指定' }, { replyTo: ctx.messageId });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const targetDir = this.dirFor(suffix);
|
|
133
|
+
if (fs.existsSync(targetDir)) {
|
|
134
|
+
await this.connector.sendWithRetry(ctx.chatId, {
|
|
135
|
+
text: `⚠️ 目录已存在:${targetDir}\n` + '请换一个名字(/clone <名字>),或省略参数随机生成',
|
|
136
|
+
}, { replyTo: ctx.messageId });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.targetDir = targetDir;
|
|
140
|
+
this.state = 'awaiting_scan';
|
|
141
|
+
getLogger().info(`[clone] start, target config dir: ${targetDir}`);
|
|
142
|
+
await this.connector.sendWithRetry(ctx.chatId, {
|
|
143
|
+
text: `🧬 开始创建分身\n` +
|
|
144
|
+
`新配置目录:${targetDir}\n` +
|
|
145
|
+
`正在生成二维码,请稍候…(完成后会以图片消息发送)`,
|
|
146
|
+
}, { replyTo: ctx.messageId });
|
|
147
|
+
this.runRegistration(ctx);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* awaiting_scan / finalizing 阶段的一切入站消息都进这里(index.ts 在
|
|
151
|
+
* 消息入队前拦截,不转发 coding agent、不进命令分发)。
|
|
152
|
+
*/
|
|
153
|
+
async handleMessage(content, ctx) {
|
|
154
|
+
if (this.state === 'finalizing') {
|
|
155
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: '⏳ 正在完成分身创建(写配置/绑定/拉起新实例),请稍候…' }, { replyTo: ctx.messageId });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const trimmed = content.trim();
|
|
159
|
+
const lowered = trimmed.toLowerCase();
|
|
160
|
+
if (EXIT_COMMANDS.has(lowered)) {
|
|
161
|
+
this.state = 'idle';
|
|
162
|
+
this.targetDir = undefined;
|
|
163
|
+
// 置空 controller 使旧注册变为过期代际:其迟到 resolve/reject 都被静默。
|
|
164
|
+
this.abortController?.abort();
|
|
165
|
+
this.abortController = undefined;
|
|
166
|
+
getLogger().info('[clone] cancelled by user');
|
|
167
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: '已取消创建分身,未写入任何配置。' }, { replyTo: ctx.messageId });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (RESEND_COMMANDS.has(trimmed)) {
|
|
171
|
+
// abort 旧注册(变为过期代际,其迟到 resolve/reject 被静默),
|
|
172
|
+
// 再起新一轮注册。awaiting_scan 状态保持不变。
|
|
173
|
+
this.abortController?.abort();
|
|
174
|
+
getLogger().info('[clone] qr resend requested');
|
|
175
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: '🔄 正在重新生成二维码…' }, { replyTo: ctx.messageId });
|
|
176
|
+
this.runRegistration(ctx);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await this.connector.sendWithRetry(ctx.chatId, {
|
|
180
|
+
text: '🧬 正在创建分身,当前状态:等待你扫码\n' +
|
|
181
|
+
'• 直接用飞书扫描上面的二维码图片即可,无需输入任何内容\n' +
|
|
182
|
+
'• 二维码过期或创建失败:输入「重发」重新生成\n' +
|
|
183
|
+
'• 退出本流程:输入 /Q(不会写入任何配置)',
|
|
184
|
+
}, { replyTo: ctx.messageId });
|
|
185
|
+
}
|
|
186
|
+
dirFor(suffix) {
|
|
187
|
+
return `${this.configDir}-${suffix}`;
|
|
188
|
+
}
|
|
189
|
+
/** 起一轮扫码注册:QR 就绪即发图,成功 finalize,失败回引导(保持流程)。 */
|
|
190
|
+
runRegistration(ctx) {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
this.abortController = controller;
|
|
193
|
+
this.registerAppFn({
|
|
194
|
+
source: 'lark-remote',
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
createOnly: true,
|
|
197
|
+
onQRCodeReady: (info) => {
|
|
198
|
+
void this.sendQrCode(info, ctx);
|
|
199
|
+
},
|
|
200
|
+
onStatusChange: (info) => {
|
|
201
|
+
if (info.status === 'domain_switched') {
|
|
202
|
+
getLogger().info('[clone] international tenant detected, switched to larksuite.com');
|
|
203
|
+
}
|
|
204
|
+
else if (info.status === 'slow_down') {
|
|
205
|
+
getLogger().info('[clone] registration polling slowed down');
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
}).then((result) => {
|
|
209
|
+
// 代际判定:/Q 或重发后本注册已是过期代际,忽略迟到 resolve,
|
|
210
|
+
// 防止旧凭证 finalize 覆盖新一轮。
|
|
211
|
+
if (this.abortController !== controller) {
|
|
212
|
+
getLogger().info('[clone] stale registration resolved, ignored');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
void this.finalize(result, ctx);
|
|
216
|
+
}, (err) => {
|
|
217
|
+
if (this.abortController !== controller) {
|
|
218
|
+
// /Q 取消或重发触发的 abort:用户侧已有对应提示,静默即可。
|
|
219
|
+
getLogger().info('[clone] stale registration aborted');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.onRegistrationError(err, ctx);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async sendQrCode(info, ctx) {
|
|
226
|
+
const qrPath = path.join(os.tmpdir(), `lark-remote-clone-qr-${crypto.randomUUID()}.gif`);
|
|
227
|
+
const mins = Math.max(1, Math.round(info.expireIn / 60));
|
|
228
|
+
try {
|
|
229
|
+
fs.writeFileSync(qrPath, renderQrImage(info.url));
|
|
230
|
+
await this.connector.sendImage(ctx.chatId, qrPath);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
getLogger().warn('[clone] send QR image failed:', err);
|
|
234
|
+
// 图片发送失败不阻断流程:给出浏览器打开兜底。
|
|
235
|
+
await this.connector
|
|
236
|
+
.sendWithRetry(ctx.chatId, { text: `⚠️ 二维码图片发送失败,请在浏览器打开创建:${info.url}` }, { replyTo: ctx.messageId })
|
|
237
|
+
.catch(() => { });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
silentlyUnlink(qrPath);
|
|
242
|
+
}
|
|
243
|
+
await this.connector
|
|
244
|
+
.sendWithRetry(ctx.chatId, {
|
|
245
|
+
text: `📱 请用飞书 App 扫描上面的二维码创建新应用(约 ${mins} 分钟内有效)\n` +
|
|
246
|
+
`也可以直接在浏览器打开:${info.url}\n\n` +
|
|
247
|
+
`• 创建成功后会自动完成绑定并启动新实例,无需再做任何输入\n` +
|
|
248
|
+
`• 需要重新生成:输入「重发」\n` +
|
|
249
|
+
`• 退出:输入 /Q`,
|
|
250
|
+
}, { replyTo: ctx.messageId })
|
|
251
|
+
.catch((err) => getLogger().warn('[clone] QR guidance send failed:', err));
|
|
252
|
+
}
|
|
253
|
+
onRegistrationError(err, ctx) {
|
|
254
|
+
// 真实失败(二维码过期/网络等):保持 awaiting_scan,让用户选「重发」或 /Q。
|
|
255
|
+
getLogger().warn('[clone] registration failed:', err);
|
|
256
|
+
void this.connector
|
|
257
|
+
.sendWithRetry(ctx.chatId, {
|
|
258
|
+
text: '⚠️ 应用创建失败或二维码已过期\n' +
|
|
259
|
+
'• 输入「重发」重新生成二维码\n' +
|
|
260
|
+
'• 输入 /Q 退出本流程',
|
|
261
|
+
}, { replyTo: ctx.messageId })
|
|
262
|
+
.catch((sendErr) => getLogger().warn('[clone] failure notice send failed:', sendErr));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 扫码成功后的自动绑定落地:
|
|
266
|
+
* 1. 复制当前 config.yaml,仅替换 feishu.appId/appSecret;
|
|
267
|
+
* 2. 同步 workspace / 常用指令 / 会话状态(session 按 openId 重绑);
|
|
268
|
+
* 3. 用 registerApp 返回的新应用 openId 写 startup-contact.json(免认领绑定);
|
|
269
|
+
* 4. detached 拉起指向新目录的新实例(启动后自动向新应用发启动通知);
|
|
270
|
+
* 5. 在原应用回执「绑定完成 + 配置目录」,退出流程。
|
|
271
|
+
*/
|
|
272
|
+
async finalize(result, ctx) {
|
|
273
|
+
this.state = 'finalizing';
|
|
274
|
+
const targetDir = this.targetDir;
|
|
275
|
+
if (!targetDir) {
|
|
276
|
+
this.state = 'idle';
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const raw = fs.readFileSync(this.configPath, 'utf-8');
|
|
281
|
+
const cloned = YAML.parse(raw);
|
|
282
|
+
const feishu = (cloned.feishu ?? {});
|
|
283
|
+
feishu['appId'] = result.client_id;
|
|
284
|
+
feishu['appSecret'] = result.client_secret;
|
|
285
|
+
cloned['feishu'] = feishu;
|
|
286
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
287
|
+
atomicWrite(path.join(targetDir, 'config.yaml'), YAML.stringify(cloned));
|
|
288
|
+
getLogger().info(`[clone] config written to ${targetDir} (appId=${result.client_id})`);
|
|
289
|
+
// 全局状态文件(workspace 别名 / 常用指令)与主键化状态(会话)同步
|
|
290
|
+
this.copyGlobalStateFiles(targetDir);
|
|
291
|
+
// 自动绑定:openId 是新应用作用域的,只能来自 registerApp 的 user_info。
|
|
292
|
+
let sessionSynced = false;
|
|
293
|
+
const newOpenId = result.user_info?.open_id;
|
|
294
|
+
if (newOpenId) {
|
|
295
|
+
atomicWriteJson(path.join(targetDir, 'startup-contact.json'), { userId: newOpenId });
|
|
296
|
+
getLogger().info(`[clone] pre-bound owner openId=${newOpenId} in new config dir`);
|
|
297
|
+
sessionSynced = this.copySessionState(targetDir, newOpenId);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
getLogger().warn('[clone] registerApp returned no user open_id; skip pre-bind');
|
|
301
|
+
}
|
|
302
|
+
const spawnPid = this.spawnNewInstanceFn(targetDir);
|
|
303
|
+
getLogger().info(`[clone] new instance spawn pid=${spawnPid ?? 'failed'}`);
|
|
304
|
+
const lines = [
|
|
305
|
+
'✅ 分身创建完成,已自动绑定',
|
|
306
|
+
`• 新应用 App ID:${result.client_id}`,
|
|
307
|
+
`• 配置目录:${targetDir}`,
|
|
308
|
+
];
|
|
309
|
+
if (sessionSynced) {
|
|
310
|
+
lines.push('• workspace / 常用指令 / 会话与工作目录状态已同步');
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
lines.push('• workspace / 常用指令状态已同步');
|
|
314
|
+
}
|
|
315
|
+
if (!newOpenId) {
|
|
316
|
+
lines.push('• 未拿到新应用侧的用户 ID:首次向新应用发消息时会自动完成绑定');
|
|
317
|
+
}
|
|
318
|
+
if (spawnPid !== undefined) {
|
|
319
|
+
lines.push(`• 新实例已自动启动(pid=${spawnPid}),启动后会向新应用发送启动通知`);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
lines.push(`• 新实例自动启动失败,请手动启动:lark-remote --config-dir ${targetDir}`);
|
|
323
|
+
}
|
|
324
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: lines.join('\n') });
|
|
325
|
+
getLogger().info('[clone] finalized, flow exited');
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
getLogger().error('[clone] finalize failed:', err);
|
|
329
|
+
this.cleanupPartialDir(targetDir);
|
|
330
|
+
await this.connector
|
|
331
|
+
.sendWithRetry(ctx.chatId, {
|
|
332
|
+
text: `⚠️ 分身配置写入失败:${err.message}\n` +
|
|
333
|
+
`新应用凭证(请妥善保管):App ID ${result.client_id}\n` +
|
|
334
|
+
'可修复本机问题后用 /clone 重新走一遍流程(会创建新应用)',
|
|
335
|
+
}, { replyTo: ctx.messageId })
|
|
336
|
+
.catch(() => { });
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
this.state = 'idle';
|
|
340
|
+
this.targetDir = undefined;
|
|
341
|
+
this.abortController = undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** 全局状态文件(workspace 别名 / 常用指令)原样复制到分身目录。 */
|
|
345
|
+
copyGlobalStateFiles(targetDir) {
|
|
346
|
+
for (const name of ['workspace.json', 'orders.json']) {
|
|
347
|
+
const src = path.join(this.configDir, name);
|
|
348
|
+
if (fs.existsSync(src)) {
|
|
349
|
+
// 源文件由旧实例原子写维护,copyFile 读到的总是完整版本
|
|
350
|
+
fs.copyFileSync(src, path.join(targetDir, name));
|
|
351
|
+
getLogger().info(`[clone] state file copied: ${name}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* last-session.json 按 openId 重绑后复制。SessionStore 以 userId 为主键,
|
|
357
|
+
* 旧应用的 openId 在新应用无效:只迁移旧 owner 的条目并挂到新 openId 名下
|
|
358
|
+
* (cwd / 各 agent sessionId / 停车位 / 到达基线 / sessionCwds 原样保留——
|
|
359
|
+
* session 文件在同一台机器上,分身实例自动恢复即可续聊)。其余用户条目
|
|
360
|
+
* 不迁移:分身实例只服务新 owner。
|
|
361
|
+
*/
|
|
362
|
+
copySessionState(targetDir, newOpenId) {
|
|
363
|
+
const src = path.join(this.configDir, 'last-session.json');
|
|
364
|
+
if (!fs.existsSync(src))
|
|
365
|
+
return false;
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = JSON.parse(fs.readFileSync(src, 'utf-8'));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
getLogger().warn('[clone] last-session.json unreadable, skip session state copy');
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
const oldOwnerId = this.readOldOwnerId();
|
|
375
|
+
const entry = oldOwnerId !== undefined ? parsed[oldOwnerId] : undefined;
|
|
376
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
377
|
+
getLogger().info('[clone] no session entry for previous owner, skip last-session copy');
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
atomicWriteJson(path.join(targetDir, 'last-session.json'), { [newOpenId]: entry });
|
|
381
|
+
getLogger().info(`[clone] session state re-keyed ${oldOwnerId} -> ${newOpenId}`);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
/** 旧实例绑定的 owner openId(来自当前目录 startup-contact.json)。 */
|
|
385
|
+
readOldOwnerId() {
|
|
386
|
+
try {
|
|
387
|
+
const raw = JSON.parse(fs.readFileSync(path.join(this.configDir, 'startup-contact.json'), 'utf-8'));
|
|
388
|
+
return typeof raw.userId === 'string' && raw.userId ? raw.userId : undefined;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* 写配置失败时清理半成品目录。守卫:只删内容全部是本次产物
|
|
396
|
+
* (config.yaml / startup-contact.json 及其 .tmp)的目录,混入其他
|
|
397
|
+
* 文件时保留现场不动。
|
|
398
|
+
*/
|
|
399
|
+
cleanupPartialDir(targetDir) {
|
|
400
|
+
try {
|
|
401
|
+
const allowed = /^(config\.yaml|startup-contact\.json)(\.tmp)?$/;
|
|
402
|
+
const entries = fs.readdirSync(targetDir);
|
|
403
|
+
if (!entries.every((e) => allowed.test(e))) {
|
|
404
|
+
getLogger().warn(`[clone] target dir has foreign entries, kept: ${targetDir}`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
408
|
+
getLogger().info(`[clone] partial target dir removed: ${targetDir}`);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// 目录不存在等:无需清理
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=clone.js.map
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* - catalog 模式(config.toml 声明 model_catalog_json):模型列表/档位来自活动目录
|
|
9
9
|
* - 非 catalog 模式:openai 用 bundled 全量;anthropic 仅在用户显式配置时存在
|
|
10
10
|
*/
|
|
11
|
-
import { execFileSync, execFile } from 'node:child_process';
|
|
12
11
|
import fs from 'node:fs';
|
|
13
12
|
import os from 'node:os';
|
|
14
13
|
import path from 'node:path';
|
|
15
14
|
import TOML from '@iarna/toml';
|
|
16
15
|
import { getLogger } from '../logger/index.js';
|
|
16
|
+
import { spawnProcess, spawnProcessSync } from '../platform/spawn.js';
|
|
17
|
+
/** codex CLI 名(cross-spawn 负责 win32 .cmd 垫片解析)。 */
|
|
18
|
+
const CODEX_BINARY = 'codex';
|
|
19
|
+
/** codex CLI 调用超时 / 输出上限(同步与异步路径一致)。 */
|
|
20
|
+
const CODEX_TIMEOUT_MS = 8_000;
|
|
21
|
+
const CODEX_MAX_BUFFER = 4 * 1024 * 1024;
|
|
17
22
|
/**
|
|
18
23
|
* Resolve codex home directory.
|
|
19
24
|
* Priority: explicit codexHome param > $CODEX_HOME env > ~/.codex
|
|
@@ -170,14 +175,7 @@ export function getCodexCatalogModels(codexHome) {
|
|
|
170
175
|
}
|
|
171
176
|
const now = Date.now();
|
|
172
177
|
try {
|
|
173
|
-
const stdout =
|
|
174
|
-
encoding: 'utf-8',
|
|
175
|
-
timeout: 8_000,
|
|
176
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
177
|
-
env: process.env,
|
|
178
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
179
|
-
windowsHide: true, // codex 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
|
|
180
|
-
});
|
|
178
|
+
const stdout = runCodexCommandSync(catalogArgs(bundled));
|
|
181
179
|
const models = parseCodexModelsOutput(stdout);
|
|
182
180
|
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
183
181
|
return models;
|
|
@@ -208,7 +206,7 @@ export async function loadCodexCatalogModelsAsync(codexHome) {
|
|
|
208
206
|
const p = (async () => {
|
|
209
207
|
const now = Date.now();
|
|
210
208
|
try {
|
|
211
|
-
const stdout = await
|
|
209
|
+
const stdout = await runCodexCommandAsync(catalogArgs(bundled));
|
|
212
210
|
const models = parseCodexModelsOutput(stdout);
|
|
213
211
|
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
214
212
|
return models;
|
|
@@ -236,7 +234,7 @@ export function warmCodexCatalogCache(codexHome) {
|
|
|
236
234
|
}
|
|
237
235
|
/** 计算目录缓存键(binary/home/mode + 指纹),同步与异步路径共享。 */
|
|
238
236
|
function computeCodexCatalogKey(codexHome) {
|
|
239
|
-
const binary =
|
|
237
|
+
const binary = CODEX_BINARY;
|
|
240
238
|
const home = resolveCodexHome(codexHome);
|
|
241
239
|
const catalogPath = readModelCatalogJsonPath(home);
|
|
242
240
|
const bundled = catalogPath === undefined || catalogPath === TOML_PARSE_FAILED_SENTINEL;
|
|
@@ -277,20 +275,77 @@ function readCachedCatalog(key) {
|
|
|
277
275
|
function catalogArgs(bundled) {
|
|
278
276
|
return bundled ? ['debug', 'models', '--bundled'] : ['debug', 'models'];
|
|
279
277
|
}
|
|
280
|
-
/**
|
|
281
|
-
|
|
278
|
+
/**
|
|
279
|
+
* 同步运行 codex CLI(`debug models ...`),成功返回 stdout。
|
|
280
|
+
*
|
|
281
|
+
* 必须经 platform/spawn(cross-spawn)而非裸 execFileSync:codex 是 npm 全局
|
|
282
|
+
* 安装的 .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析 —— 裸名 'codex'
|
|
283
|
+
* 会被解析到无扩展名的 sh 脚本(给 Git Bash/WSL 用的那份),Windows 原生
|
|
284
|
+
* 执行直接失败(bun 下 exit 1「系统找不到指定的路径」,node 下 ENOENT);
|
|
285
|
+
* 而 execFileSync('codex.cmd') 又因 execFile 不经 shell 直接 EINVAL。
|
|
286
|
+
* cross-spawn 与 runner spawn 走同一垫片执行路径(posix 直通)。
|
|
287
|
+
* stderr 参与 pipe:失败信息带上 stderr 片段,避免只留一句无诊断价值的
|
|
288
|
+
* "Command failed"(历史教训:真因只存在于被丢弃的 stderr 里)。
|
|
289
|
+
*/
|
|
290
|
+
function runCodexCommandSync(args) {
|
|
291
|
+
const res = spawnProcessSync(CODEX_BINARY, args, {
|
|
292
|
+
encoding: 'utf-8',
|
|
293
|
+
timeout: CODEX_TIMEOUT_MS,
|
|
294
|
+
maxBuffer: CODEX_MAX_BUFFER,
|
|
295
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
296
|
+
});
|
|
297
|
+
if (res.error)
|
|
298
|
+
throw res.error;
|
|
299
|
+
if (res.status !== 0) {
|
|
300
|
+
const stderr = String(res.stderr ?? '').trim();
|
|
301
|
+
throw new Error(`codex exited ${res.status}${stderr ? `: ${stderr.slice(0, 200)}` : ''}`);
|
|
302
|
+
}
|
|
303
|
+
return String(res.stdout ?? '');
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 异步运行 codex CLI(spawnProcess + 流式收集),成功返回完整 stdout。
|
|
307
|
+
* 与同步路径同超时 / 输出上限;stdout 超 4MB 主动 kill(对齐 execFile maxBuffer 语义)。
|
|
308
|
+
*/
|
|
309
|
+
function runCodexCommandAsync(args) {
|
|
282
310
|
return new Promise((resolve, reject) => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
311
|
+
const child = spawnProcess(CODEX_BINARY, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
312
|
+
let stdout = '';
|
|
313
|
+
let stderr = '';
|
|
314
|
+
let settled = false;
|
|
315
|
+
const settle = (fn) => {
|
|
316
|
+
if (settled)
|
|
317
|
+
return;
|
|
318
|
+
settled = true;
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
fn();
|
|
321
|
+
};
|
|
322
|
+
const timer = setTimeout(() => {
|
|
323
|
+
settle(() => {
|
|
324
|
+
child.kill();
|
|
325
|
+
reject(new Error(`codex timed out after ${CODEX_TIMEOUT_MS}ms`));
|
|
326
|
+
});
|
|
327
|
+
}, CODEX_TIMEOUT_MS);
|
|
328
|
+
child.stdout?.on('data', (chunk) => {
|
|
329
|
+
stdout += String(chunk);
|
|
330
|
+
if (stdout.length > CODEX_MAX_BUFFER) {
|
|
331
|
+
settle(() => reject(new Error('codex stdout exceeded maxBuffer (4MB)')));
|
|
332
|
+
child.kill();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
child.stderr?.on('data', (chunk) => {
|
|
336
|
+
stderr += String(chunk);
|
|
337
|
+
});
|
|
338
|
+
child.on('error', (err) => settle(() => reject(err)));
|
|
339
|
+
child.on('close', (code) => {
|
|
340
|
+
settle(() => {
|
|
341
|
+
if (code === 0) {
|
|
342
|
+
resolve(stdout);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const tail = stderr.trim().slice(0, 200);
|
|
346
|
+
reject(new Error(`codex exited ${code}${tail ? `: ${tail}` : ''}`));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
294
349
|
});
|
|
295
350
|
});
|
|
296
351
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Dynamically reads available models from `kimi provider list --json`,
|
|
5
5
|
* provides dynamic dropdown options for /config card.
|
|
6
6
|
*/
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
7
|
import { getLogger } from '../logger/index.js';
|
|
8
|
+
import { spawnProcessSync } from '../platform/spawn.js';
|
|
9
9
|
/** Valid thinking effort values for kimi (Layer 1: Schema enum source) */
|
|
10
10
|
export const KIMI_THINKING_EFFORTS = ['low', 'high', 'max'];
|
|
11
11
|
/** Fallback efforts when all values are filtered out */
|
|
@@ -30,13 +30,24 @@ export function loadKimiConfig() {
|
|
|
30
30
|
}
|
|
31
31
|
const logger = getLogger();
|
|
32
32
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// spawnProcessSync(cross-spawn)而非 execFileSync:kimi 是 npm 全局安装的
|
|
34
|
+
// .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析(裸名解析到无扩展名的
|
|
35
|
+
// sh 脚本直接执行失败,.cmd 又 EINVAL);cross-spawn 与 runner spawn 走
|
|
36
|
+
// 同一垫片执行路径(posix 直通)。stderr 参与 pipe 以便失败信息可诊断。
|
|
37
|
+
const res = spawnProcessSync('kimi', ['provider', 'list', '--json'], {
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
39
|
timeout: 10000,
|
|
36
40
|
maxBuffer: 2 * 1024 * 1024,
|
|
37
|
-
|
|
41
|
+
encoding: 'utf-8',
|
|
38
42
|
});
|
|
39
|
-
|
|
43
|
+
if (res.error)
|
|
44
|
+
throw res.error;
|
|
45
|
+
if (res.status !== 0) {
|
|
46
|
+
const stderr = String(res.stderr ?? '').trim();
|
|
47
|
+
throw new Error(`kimi exited ${res.status}${stderr ? `: ${stderr.slice(0, 200)}` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
const output = String(res.stdout ?? '');
|
|
50
|
+
const data = JSON.parse(output);
|
|
40
51
|
if (!data.models || Object.keys(data.models).length === 0) {
|
|
41
52
|
logger.warn('[kimi-config] no models found in provider list, using defaults');
|
|
42
53
|
return buildDefaultResult();
|
|
@@ -140,11 +140,30 @@ export declare class FeishuConnector {
|
|
|
140
140
|
* The SDK resolves the reaction id internally; missing reaction is a no-op.
|
|
141
141
|
*/
|
|
142
142
|
removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
|
|
145
|
+
* im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
|
|
146
|
+
*/
|
|
147
|
+
private checkUploadable;
|
|
148
|
+
/**
|
|
149
|
+
* sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
|
|
150
|
+
* → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
|
|
151
|
+
* 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
|
|
152
|
+
* (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
|
|
153
|
+
* 标签('sendFile' | 'sendImage')。
|
|
154
|
+
*/
|
|
155
|
+
private uploadAndSendMedia;
|
|
143
156
|
/**
|
|
144
157
|
* Upload a file to Feishu and send it to the specified chat.
|
|
145
158
|
* Uses the im/v1/files upload API.
|
|
146
159
|
*/
|
|
147
160
|
sendFile(chatId: string, filePath: string): Promise<string>;
|
|
161
|
+
/**
|
|
162
|
+
* Upload a local image file to Feishu and send it as an image message
|
|
163
|
+
* (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
|
|
164
|
+
* scan-to-create QR code inline instead of as a downloadable file.
|
|
165
|
+
*/
|
|
166
|
+
sendImage(chatId: string, filePath: string): Promise<string>;
|
|
148
167
|
/**
|
|
149
168
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
150
169
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|