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 CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-09-13
9
+
10
+ ### 新增
11
+
12
+ - **`/clone [name]`(复制分身)**:把当前配置目录克隆为一份新分身(`<configDir>-<name>`,省略名字时随机生成后缀;除飞书 `appId`/`appSecret` 外全部复制),引导你扫码创建全新飞书应用——二维码以图片消息直接发进私聊,约 10 分钟内有效。扫码成功后自动完成绑定并把 workspace 别名、常用指令、工作目录与会话状态一并同步到分身,随后自动拉起新实例(新应用会收到它的启动通知),原应用回执新应用 App ID、配置目录与进程号。等待扫码期间消息不转发给 agent:输入「重发」重新生成二维码,`/Q` 取消。`/help` 卡片的「/clone」按钮可直接点击开始
13
+ - **一键复制分身按钮**:`/help` 卡片按钮区新增「/clone」,与无参 `/clone` 等价
14
+
15
+ ### 变更
16
+
17
+ - **首次绑定简化**:未绑定时首条私聊消息(任意内容)即完成 owner 绑定,移除原 4 位 PIN。飞书自建应用私聊入口本身由 owner 掌控,把应用分享出去才属主动行为;绑定成功会回执确认
18
+
19
+ ### 修复
20
+
21
+ - **Codex / Kimi CLI 版本探测走平台 spawn 收口**:CLI 可执行文件探测统一经由跨平台 spawn 层,修复 Windows 下的探测兼容性问题
22
+ - **`bun run test:live` 退出码**:在 `bun run` 下正确透传退出码,不再误报失败
23
+
8
24
  ## [0.2.2] - 2026-09-12
9
25
 
10
26
  ### 修复
package/README.en.md CHANGED
@@ -118,6 +118,7 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
118
118
  | `/stop` | `/t` | Kill the current agent process (SIGKILL) |
119
119
  | `/ps` | - | Whether a process is running |
120
120
  | `/restart` | - | In-place bridge self-restart (new process takes over with the same config) |
121
+ | `/clone [name]` | - | Clone the bridge: copies the current config into a new directory and walks you through creating a brand-new Feishu app by QR scan; on success it binds, syncs state, and launches the new instance |
121
122
  | `/config get\|set` | `/c` | View/change runtime config (agent-aware card) |
122
123
  | `/order save\|list\|edit` | `/o` | Save, list, or edit frequently used prompts |
123
124
  | `!<cmd>` | - | Run a bash command with streaming card output (bypasses the serial queue) |
@@ -125,6 +126,8 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
125
126
 
126
127
  Any message not starting with `/` is forwarded to the current default agent.
127
128
 
129
+ **Clone the bridge (`/clone`)**: to run an independent bridge instance next to another project directory, send `/clone <name>` — the current config directory is cloned to `<configDir>-<name>` (everything except the Feishu credentials), a QR code is sent into the chat as an image to create a brand-new Feishu app, and once you scan it the new app is bound automatically with workspace aliases, saved prompts, working directory, and session state carried over; the new instance launches itself and is ready to chat. You can also hit the "/clone" button on the `/help` card. See [docs/en/usage.md](docs/en/usage.md) for details.
130
+
128
131
  ## Testing
129
132
 
130
133
  ```bash
package/README.md CHANGED
@@ -118,6 +118,7 @@ bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYY
118
118
  | `/stop` | `/t` | 终止当前 agent 进程(SIGKILL) |
119
119
  | `/ps` | - | 是否有进程在跑 |
120
120
  | `/restart` | - | 原地自重启 bridge(新进程同 config 接管) |
121
+ | `/clone [name]` | - | 复制分身:克隆当前配置到新目录并扫码创建新飞书应用,完成后自动绑定、同步状态并拉起新实例 |
121
122
  | `/config get\|set` | `/c` | 查改运行时配置(agent-aware 卡片) |
122
123
  | `/order save\|list\|edit\|alias` | `/o` | 收藏常用指令;`/order edit` 修改指令文本(保留别名/使用统计);`/order alias` 注册快捷别名(输入 `$name` 展开) |
123
124
  | `!<cmd>` | - | 执行 bash 命令并流式输出到卡片(绕过串行队列) |
@@ -125,6 +126,8 @@ bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYY
125
126
 
126
127
  直接发非 `/` 开头的消息即转发给当前默认 agent。
127
128
 
129
+ **复制分身(`/clone`)**:想在另一个项目/机器目录旁跑一个独立 bridge 实例时,发 `/clone 名字` 即可——当前配置目录会被克隆为 `<configDir>-<名字>`(除飞书凭据外全部复制),二维码以图片消息发进私聊引导你扫码创建一个全新飞书应用;扫码成功后自动完成绑定,workspace 别名、常用指令、工作目录与会话状态一并迁移,新实例自动拉起并可直接续聊。也可直接点 `/help` 卡片上的「/clone」按钮。详见 [docs/zh/usage.md](docs/zh/usage.md)。
130
+
128
131
  往飞书私聊发**图片/文件**会自动保存到当前目录的 `.lark-remote-temp/<YYYYMMDDHHmm>/`
129
132
  (建议把 `.lark-remote-temp/` 加进项目 `.gitignore`),随后说「请处理刚才保存的文件」
130
133
  即可让 agent 直接读本地文件。更多用法见 [docs/zh/usage.md](docs/zh/usage.md)。
package/dist/binder.d.ts CHANGED
@@ -6,31 +6,25 @@ type BindDecision = {
6
6
  kind: 'rejected';
7
7
  } | {
8
8
  kind: 'bind_success';
9
- } | {
10
- kind: 'pin_wrong';
11
9
  };
12
10
  /**
13
- * Owner 绑定器:首次私聊输入正确 PIN 完成"认领",此后仅该 openId 可用。
11
+ * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
14
12
  *
15
- * 设计依据(第一性原理):bridge 进程持有用户本机完整权限,唯一合法主体是
16
- * owner 本人。任何入站事件都必须认证到该单一主体。`chatType==='p2p'` 不是认证
17
- * (任何能私聊机器人的用户都满足)。本类以"首次绑定 + openId 强校验"实现认证,
18
- * 复用已有 startup-contact.json,不新增配置字段。
13
+ * 设计依据:bridge 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
14
+ * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
15
+ * 分享出去属于 owner 的主动行为),因此首次消息即绑定,不再要求输入 PIN。
16
+ * 本类复用已有 startup-contact.json,不新增配置字段。
19
17
  *
20
18
  * 状态机:
21
- * - 未绑定:生成 PIN(控制台展示),等待 owner 私聊输入正确 PIN -> 写入绑定。
22
- * 输错 PIN 完全静默(不回复、不提醒);首次启动仅用户本机可见,无需严格防枚举。
19
+ * - 未绑定:第一条私聊消息(任意内容)即完成绑定 -> 写入 startup-contact.json。
23
20
  * - 已绑定:仅 senderId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
24
21
  *
25
22
  * cardAction 同样校验 operator.openId === bound.userId(未绑定也视为非 owner)。
26
23
  */
27
24
  export declare class OwnerBinder {
28
25
  private readonly store;
29
- private pin;
30
26
  private rejectedTotal;
31
27
  constructor(store: StartupContactStore);
32
- /** 仅未绑定时返回待输入的 PIN(控制台展示用)。 */
33
- get pendingPin(): string | undefined;
34
28
  isBound(): boolean;
35
29
  /** 已绑定的 owner openId;未绑定时 undefined。 */
36
30
  boundOpenId(): string | undefined;
@@ -42,16 +36,15 @@ export declare class OwnerBinder {
42
36
  * 对入站私聊消息做绑定/授权判定,含副作用:
43
37
  * - `owner`:放行,调用方继续正常处理
44
38
  * - `rejected`:已绑定但非 owner,静默丢弃(计数 + debug)
45
- * - `bind_success`:未绑定且 PIN 正确,写入绑定并清除 PIN
46
- * - `pin_wrong`:未绑定且 PIN 错误,静默丢弃(不回复、不提醒)
39
+ * - `bind_success`:未绑定收到首条消息,写入绑定(任意内容均可)
47
40
  */
48
41
  classify(senderId: string, content: string, chatId: string): BindDecision;
49
42
  /** 卡片操作被拒时计数(与消息路径共用计数器)。 */
50
43
  recordRejectedCardAction(openId: string): void;
51
44
  }
52
45
  /**
53
- * 控制台 PIN 引导文案(首次未绑定时输出到 stderr;守护模式下被 watchdog
46
+ * 控制台绑定引导文案(首次未绑定时输出到 stderr;守护模式下被 watchdog
54
47
  * 重定向到 daemon 日志)。绑定后此文案不再出现。
55
48
  */
56
- export declare function formatPinGuidance(pin: string): string;
49
+ export declare function formatBindGuidance(): string;
57
50
  export {};
package/dist/binder.js CHANGED
@@ -1,40 +1,23 @@
1
- import crypto from 'node:crypto';
2
1
  import { getLogger } from './logger/index.js';
3
- /** 绑定 PIN 位数。4 位数字便于人工输入。 */
4
- const PIN_DIGITS = 4;
5
- /** 用 CSPRNG 生成固定长度、零填充的数字 PIN(首位可为 0)。 */
6
- function generatePin() {
7
- const n = crypto.randomInt(0, 10 ** PIN_DIGITS);
8
- return String(n).padStart(PIN_DIGITS, '0');
9
- }
10
2
  /**
11
- * Owner 绑定器:首次私聊输入正确 PIN 完成"认领",此后仅该 openId 可用。
3
+ * Owner 绑定器:首次私聊发送任意消息完成"认领",此后仅该 openId 可用。
12
4
  *
13
- * 设计依据(第一性原理):bridge 进程持有用户本机完整权限,唯一合法主体是
14
- * owner 本人。任何入站事件都必须认证到该单一主体。`chatType==='p2p'` 不是认证
15
- * (任何能私聊机器人的用户都满足)。本类以"首次绑定 + openId 强校验"实现认证,
16
- * 复用已有 startup-contact.json,不新增配置字段。
5
+ * 设计依据:bridge 进程持有用户本机完整权限,唯一合法主体是 owner 本人。
6
+ * 飞书自建应用的私聊入口本身就是 owner 掌控的(应用只有 owner 能看到/使用,
7
+ * 分享出去属于 owner 的主动行为),因此首次消息即绑定,不再要求输入 PIN。
8
+ * 本类复用已有 startup-contact.json,不新增配置字段。
17
9
  *
18
10
  * 状态机:
19
- * - 未绑定:生成 PIN(控制台展示),等待 owner 私聊输入正确 PIN -> 写入绑定。
20
- * 输错 PIN 完全静默(不回复、不提醒);首次启动仅用户本机可见,无需严格防枚举。
11
+ * - 未绑定:第一条私聊消息(任意内容)即完成绑定 -> 写入 startup-contact.json。
21
12
  * - 已绑定:仅 senderId === bound.userId 的消息放行;其余静默丢弃(计数 + debug)。
22
13
  *
23
14
  * cardAction 同样校验 operator.openId === bound.userId(未绑定也视为非 owner)。
24
15
  */
25
16
  export class OwnerBinder {
26
17
  store;
27
- pin;
28
18
  rejectedTotal = 0;
29
19
  constructor(store) {
30
20
  this.store = store;
31
- if (!store.getContact()) {
32
- this.pin = generatePin();
33
- }
34
- }
35
- /** 仅未绑定时返回待输入的 PIN(控制台展示用)。 */
36
- get pendingPin() {
37
- return this.pin;
38
21
  }
39
22
  isBound() {
40
23
  return this.store.getContact() !== undefined;
@@ -56,8 +39,7 @@ export class OwnerBinder {
56
39
  * 对入站私聊消息做绑定/授权判定,含副作用:
57
40
  * - `owner`:放行,调用方继续正常处理
58
41
  * - `rejected`:已绑定但非 owner,静默丢弃(计数 + debug)
59
- * - `bind_success`:未绑定且 PIN 正确,写入绑定并清除 PIN
60
- * - `pin_wrong`:未绑定且 PIN 错误,静默丢弃(不回复、不提醒)
42
+ * - `bind_success`:未绑定收到首条消息,写入绑定(任意内容均可)
61
43
  */
62
44
  classify(senderId, content, chatId) {
63
45
  const bound = this.store.getContact();
@@ -68,16 +50,10 @@ export class OwnerBinder {
68
50
  getLogger().debug(`[binder] rejected message from ${senderId} (total rejected=${this.rejectedCount})`);
69
51
  return { kind: 'rejected' };
70
52
  }
71
- // 未绑定:要求 PIN(精确匹配 trim 后内容,避免部分匹配绕过)
72
- if (this.pin !== undefined && content.trim() === this.pin) {
73
- this.store.save({ chatId, userId: senderId });
74
- this.pin = undefined;
75
- getLogger().info(`[binder] owner bound: openId=${senderId} chatId=${chatId}`);
76
- return { kind: 'bind_success' };
77
- }
78
- // 输错 PIN:完全静默(不回复、不提醒、不计数),仅内部 debug 日志
79
- getLogger().debug('[binder] wrong pin (unbound, ignored)');
80
- return { kind: 'pin_wrong' };
53
+ // 未绑定:任意内容即绑定(首条消息完成认领)
54
+ this.store.save({ chatId, userId: senderId });
55
+ getLogger().info(`[binder] owner bound: openId=${senderId} chatId=${chatId}`);
56
+ return { kind: 'bind_success' };
81
57
  }
82
58
  /** 卡片操作被拒时计数(与消息路径共用计数器)。 */
83
59
  recordRejectedCardAction(openId) {
@@ -86,16 +62,14 @@ export class OwnerBinder {
86
62
  }
87
63
  }
88
64
  /**
89
- * 控制台 PIN 引导文案(首次未绑定时输出到 stderr;守护模式下被 watchdog
65
+ * 控制台绑定引导文案(首次未绑定时输出到 stderr;守护模式下被 watchdog
90
66
  * 重定向到 daemon 日志)。绑定后此文案不再出现。
91
67
  */
92
- export function formatPinGuidance(pin) {
68
+ export function formatBindGuidance() {
93
69
  return [
94
70
  '',
95
71
  '🔒 首次绑定',
96
- '请在飞书私聊本应用,发送以下 4 位数字完成绑定:',
97
- '',
98
- ` ${pin}`,
72
+ '请在飞书私聊本应用,发送任意消息完成绑定:',
99
73
  '',
100
74
  '绑定后仅该账号可使用本应用。',
101
75
  '需更换账号:删除 <configDir>/startup-contact.json 后重启。',
@@ -0,0 +1,139 @@
1
+ import { type QRCodeInfo, type RegisterAppResult } from '@larksuite/channel';
2
+ /**
3
+ * 复制分身(/clone)状态机。
4
+ *
5
+ * 在当前 bridge 进程内引导用户扫码创建一个新飞书应用,并生成一份"分身"
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 {};