lark-remote 0.2.2 → 0.4.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 +33 -0
- package/README.en.md +8 -5
- package/README.md +10 -6
- package/dist/binder.d.ts +9 -16
- package/dist/binder.js +14 -40
- package/dist/bridge/final-usage.js +2 -2
- package/dist/bridge/inbound-media.js +7 -5
- package/dist/bridge/index.js +75 -75
- package/dist/clone.d.ts +139 -0
- package/dist/clone.js +415 -0
- package/dist/common/fs.js +1 -1
- package/dist/config/codex-config.js +79 -24
- package/dist/config/index.js +1 -1
- package/dist/config/kimi-config.js +16 -5
- package/dist/connector/index.d.ts +25 -0
- package/dist/connector/index.js +133 -50
- package/dist/error-classification.d.ts +7 -0
- package/dist/error-classification.js +37 -1
- package/dist/index.js +48 -15
- package/dist/platform/command.js +1 -1
- package/dist/platform/sleep-blocker.d.ts +17 -0
- package/dist/platform/sleep-blocker.js +73 -0
- package/dist/platform/terminator-win32.js +1 -1
- package/dist/restart.d.ts +20 -3
- package/dist/restart.js +42 -23
- package/dist/router/config/kimi.js +4 -3
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.js +44 -6
- package/dist/runner/common/acp/base-acp-runner.js +7 -2
- package/dist/runner/common/acp/base-translator.d.ts +19 -0
- package/dist/runner/common/acp/base-translator.js +82 -0
- package/dist/runner/common/acp/protocol-types.d.ts +16 -2
- package/dist/runner/common/acp/protocol-types.js +3 -0
- package/dist/runner/kimi/acp/translator.js +60 -4
- package/dist/session/session-store.d.ts +1 -1
- package/dist/session/session-store.js +1 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.js +1 -1
- package/package.json +2 -2
package/dist/clone.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type QRCodeInfo, type RegisterAppResult } from '@larksuite/channel';
|
|
2
|
+
/**
|
|
3
|
+
* 复制分身(/clone)状态机。
|
|
4
|
+
*
|
|
5
|
+
* 在当前 lark-remote 进程内引导用户扫码创建一个新飞书应用,并生成一份"分身"
|
|
6
|
+
* 配置目录:除 feishu.appId/appSecret 来自新应用外,其余配置原样复制;
|
|
7
|
+
* 创建完成后用新应用拿到的用户 openId 直接写好绑定(owner 无需再输入
|
|
8
|
+
* 任意内容认领),并 detached 拉起指向新配置目录的新实例——启动后新
|
|
9
|
+
* 实例自己会向新应用发送启动通知,即完成"在新应用主动触达用户"。
|
|
10
|
+
*
|
|
11
|
+
* 状态机(仅在原始应用内运行):
|
|
12
|
+
* - idle:未进入流程。`/clone [name]` 进入 awaiting_scan。
|
|
13
|
+
* - awaiting_scan:二维码已发出,SDK 后台轮询等扫码。此阶段用户的一切
|
|
14
|
+
* 消息不转发 coding agent:「重发」重新生成二维码,/Q 取消,其余输入
|
|
15
|
+
* 回引导文案。
|
|
16
|
+
* - finalizing:扫码成功,正在写配置/绑定/拉起,短暂过渡态,输入被婉拒。
|
|
17
|
+
*
|
|
18
|
+
* 注册生命周期用「代际判定」管理:runRegistration 捕获本轮 AbortController,
|
|
19
|
+
* then 分支先比对 this.abortController —— /Q 取消或「重发」都会替换/清空
|
|
20
|
+
* controller,过期代际的 resolve/reject 一律静默,防止旧凭证覆盖新一轮。
|
|
21
|
+
*
|
|
22
|
+
* 安全边界:流程由已绑定 owner 在私聊里发起(router 入口已过 OwnerBinder
|
|
23
|
+
* 闸门),新配置目录写入本机,不涉及对外暴露。
|
|
24
|
+
*/
|
|
25
|
+
type CloneState = 'idle' | 'awaiting_scan' | 'finalizing';
|
|
26
|
+
/**
|
|
27
|
+
* 校验用户指定的分身名字:路径分隔符、以 . 开头、尾点/尾空格(win32 会静默
|
|
28
|
+
* 吞掉)、win32 保留设备名一律拒绝。
|
|
29
|
+
*/
|
|
30
|
+
export declare function isValidCloneName(name: string): boolean;
|
|
31
|
+
/** 随机后缀:2 位数字 + 2 位小写字母/数字,如 `37ab`。 */
|
|
32
|
+
export declare function generateCloneSuffix(): string;
|
|
33
|
+
/** clone 流程内发消息所需的上下文(router CommandContext 的结构子集)。 */
|
|
34
|
+
export interface CloneContext {
|
|
35
|
+
userId: string;
|
|
36
|
+
chatId: string;
|
|
37
|
+
messageId?: string;
|
|
38
|
+
}
|
|
39
|
+
/** clone 流程依赖的 connector 发送面(测试用 stub 结构化满足)。 */
|
|
40
|
+
export interface CloneConnector {
|
|
41
|
+
sendWithRetry(chatId: string, input: {
|
|
42
|
+
text: string;
|
|
43
|
+
} | {
|
|
44
|
+
markdown: string;
|
|
45
|
+
} | {
|
|
46
|
+
card: object;
|
|
47
|
+
}, opts?: {
|
|
48
|
+
replyTo?: string;
|
|
49
|
+
}): Promise<string>;
|
|
50
|
+
sendImage(chatId: string, filePath: string): Promise<string>;
|
|
51
|
+
}
|
|
52
|
+
type RegisterAppFn = (options: {
|
|
53
|
+
source: string;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
/** 分身必须是全新应用:复用已有应用会让两个实例抢同一份凭证。 */
|
|
56
|
+
createOnly?: boolean;
|
|
57
|
+
onQRCodeReady: (info: QRCodeInfo) => void;
|
|
58
|
+
onStatusChange?: (info: {
|
|
59
|
+
status: string;
|
|
60
|
+
}) => void;
|
|
61
|
+
}) => Promise<RegisterAppResult>;
|
|
62
|
+
/**
|
|
63
|
+
* 默认新实例拉起:同入口 + 同参数,仅替换 --config-dir(detached,返回 pid)。
|
|
64
|
+
* spawn 骨架(log fd / windowsHide / error 兜底)单源于 restart.ts。
|
|
65
|
+
*/
|
|
66
|
+
export declare function spawnDetachedCloneBridge(targetDir: string): number | undefined;
|
|
67
|
+
export interface CloneSessionOptions {
|
|
68
|
+
connector: CloneConnector;
|
|
69
|
+
/** 当前实例的 config.yaml 路径(配置复制源)。 */
|
|
70
|
+
configPath: string;
|
|
71
|
+
/** 当前实例的配置目录(分身目录 = 该目录 + '-' + 后缀/名字)。 */
|
|
72
|
+
configDir: string;
|
|
73
|
+
/** 覆盖 SDK registerApp(测试注入)。 */
|
|
74
|
+
registerAppFn?: RegisterAppFn;
|
|
75
|
+
/** 覆盖随机后缀生成(测试注入确定性序列)。 */
|
|
76
|
+
generateSuffix?: () => string;
|
|
77
|
+
/** 覆盖新实例拉起(测试注入;默认 detached spawn 同入口进程)。 */
|
|
78
|
+
spawnNewInstance?: (targetDir: string) => number | undefined;
|
|
79
|
+
}
|
|
80
|
+
export declare class CloneSession {
|
|
81
|
+
private state;
|
|
82
|
+
private targetDir;
|
|
83
|
+
private abortController;
|
|
84
|
+
private readonly connector;
|
|
85
|
+
private readonly configPath;
|
|
86
|
+
private readonly configDir;
|
|
87
|
+
private readonly registerAppFn;
|
|
88
|
+
private readonly generateSuffixFn;
|
|
89
|
+
private readonly spawnNewInstanceFn;
|
|
90
|
+
constructor(opts: CloneSessionOptions);
|
|
91
|
+
/** 当前状态(测试断言用)。 */
|
|
92
|
+
get currentState(): CloneState;
|
|
93
|
+
isActive(): boolean;
|
|
94
|
+
/** 已确定但尚未写入的分身配置目录(测试断言用)。 */
|
|
95
|
+
get pendingTargetDir(): string | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* 进入创建分身流程:确定新配置目录 → 发二维码 → 等扫码。
|
|
98
|
+
* 目录已存在 / 名字非法时不进入流程(保持 idle),仅回错误说明。
|
|
99
|
+
*/
|
|
100
|
+
start(nameArg: string | undefined, ctx: CloneContext): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* awaiting_scan / finalizing 阶段的一切入站消息都进这里(index.ts 在
|
|
103
|
+
* 消息入队前拦截,不转发 coding agent、不进命令分发)。
|
|
104
|
+
*/
|
|
105
|
+
handleMessage(content: string, ctx: CloneContext): Promise<void>;
|
|
106
|
+
private dirFor;
|
|
107
|
+
/** 起一轮扫码注册:QR 就绪即发图,成功 finalize,失败回引导(保持流程)。 */
|
|
108
|
+
private runRegistration;
|
|
109
|
+
private sendQrCode;
|
|
110
|
+
private onRegistrationError;
|
|
111
|
+
/**
|
|
112
|
+
* 扫码成功后的自动绑定落地:
|
|
113
|
+
* 1. 复制当前 config.yaml,仅替换 feishu.appId/appSecret;
|
|
114
|
+
* 2. 同步 workspace / 常用指令 / 会话状态(session 按 openId 重绑);
|
|
115
|
+
* 3. 用 registerApp 返回的新应用 openId 写 startup-contact.json(免认领绑定);
|
|
116
|
+
* 4. detached 拉起指向新目录的新实例(启动后自动向新应用发启动通知);
|
|
117
|
+
* 5. 在原应用回执「绑定完成 + 配置目录」,退出流程。
|
|
118
|
+
*/
|
|
119
|
+
private finalize;
|
|
120
|
+
/** 全局状态文件(workspace 别名 / 常用指令)原样复制到分身目录。 */
|
|
121
|
+
private copyGlobalStateFiles;
|
|
122
|
+
/**
|
|
123
|
+
* last-session.json 按 openId 重绑后复制。SessionStore 以 userId 为主键,
|
|
124
|
+
* 旧应用的 openId 在新应用无效:只迁移旧 owner 的条目并挂到新 openId 名下
|
|
125
|
+
* (cwd / 各 agent sessionId / 停车位 / 到达基线 / sessionCwds 原样保留——
|
|
126
|
+
* session 文件在同一台机器上,分身实例自动恢复即可续聊)。其余用户条目
|
|
127
|
+
* 不迁移:分身实例只服务新 owner。
|
|
128
|
+
*/
|
|
129
|
+
private copySessionState;
|
|
130
|
+
/** 旧实例绑定的 owner openId(来自当前目录 startup-contact.json)。 */
|
|
131
|
+
private readOldOwnerId;
|
|
132
|
+
/**
|
|
133
|
+
* 写配置失败时清理半成品目录。守卫:只删内容全部是本次产物
|
|
134
|
+
* (config.yaml / startup-contact.json 及其 .tmp)的目录,混入其他
|
|
135
|
+
* 文件时保留现场不动。
|
|
136
|
+
*/
|
|
137
|
+
private cleanupPartialDir;
|
|
138
|
+
}
|
|
139
|
+
export {};
|
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
|
package/dist/common/fs.js
CHANGED
|
@@ -22,7 +22,7 @@ export function silentlyUnlink(path) {
|
|
|
22
22
|
* win32 句柄占用重试:rename/unlink 目标被
|
|
23
23
|
* 其他句柄持有时报 EPERM/EBUSY(共享冲突),短暂退避后重试通常即可成功。
|
|
24
24
|
* 仅 win32 启用重试:posix 上这两个错误码不是「占用瞬态」语义(如 NFS 权限
|
|
25
|
-
* 问题),立即失败——避免忙等阻塞单线程
|
|
25
|
+
* 问题),立即失败——避免忙等阻塞单线程 lark-remote 的事件循环。
|
|
26
26
|
*/
|
|
27
27
|
const BUSY_CODES = new Set(['EPERM', 'EBUSY']);
|
|
28
28
|
/**
|