lark-remote 0.2.1 → 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 +29 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/dist/binder.d.ts +9 -16
- package/dist/binder.js +14 -40
- package/dist/bridge/index.d.ts +2 -6
- package/dist/bridge/index.js +0 -4
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +47 -3
- package/dist/clone.d.ts +139 -0
- package/dist/clone.js +415 -0
- package/dist/config/codex-config.js +79 -24
- package/dist/config/dir.js +15 -5
- package/dist/config/kimi-config.js +16 -5
- package/dist/connector/index.d.ts +19 -1
- package/dist/connector/index.js +57 -39
- package/dist/index.js +36 -13
- package/dist/platform/command.d.ts +2 -0
- package/dist/platform/command.js +54 -2
- package/dist/restart.d.ts +17 -0
- package/dist/restart.js +38 -19
- package/dist/router/index.d.ts +10 -1
- package/dist/router/index.js +59 -14
- package/package.json +2 -2
|
@@ -114,7 +114,6 @@ export declare class FeishuConnector {
|
|
|
114
114
|
}): Promise<InboundMediaPayload>;
|
|
115
115
|
connect(): Promise<void>;
|
|
116
116
|
disconnect(): Promise<void>;
|
|
117
|
-
reconnect(): Promise<void>;
|
|
118
117
|
/**
|
|
119
118
|
* Send a message with automatic rate-limit retry (§9.5).
|
|
120
119
|
* Encounters rate_limited / feishuCode 99991400|99991401(SDK 归类为
|
|
@@ -141,11 +140,30 @@ export declare class FeishuConnector {
|
|
|
141
140
|
* The SDK resolves the reaction id internally; missing reaction is a no-op.
|
|
142
141
|
*/
|
|
143
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;
|
|
144
156
|
/**
|
|
145
157
|
* Upload a file to Feishu and send it to the specified chat.
|
|
146
158
|
* Uses the im/v1/files upload API.
|
|
147
159
|
*/
|
|
148
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>;
|
|
149
167
|
/**
|
|
150
168
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
151
169
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|
package/dist/connector/index.js
CHANGED
|
@@ -288,12 +288,6 @@ export class FeishuConnector {
|
|
|
288
288
|
this.isConnected = false;
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
-
async reconnect() {
|
|
292
|
-
// disconnect() swallows its own errors internally, so no try/catch needed
|
|
293
|
-
// here — it always resolves.
|
|
294
|
-
await this.disconnect();
|
|
295
|
-
await this.connect();
|
|
296
|
-
}
|
|
297
291
|
/**
|
|
298
292
|
* Send a message with automatic rate-limit retry (§9.5).
|
|
299
293
|
* Encounters rate_limited / feishuCode 99991400|99991401(SDK 归类为
|
|
@@ -384,11 +378,10 @@ export class FeishuConnector {
|
|
|
384
378
|
}
|
|
385
379
|
}
|
|
386
380
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
381
|
+
* sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
|
|
382
|
+
* im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
|
|
389
383
|
*/
|
|
390
|
-
|
|
391
|
-
// Check file existence first with proper error handling
|
|
384
|
+
checkUploadable(filePath, noun) {
|
|
392
385
|
let stat;
|
|
393
386
|
try {
|
|
394
387
|
stat = fs.statSync(filePath);
|
|
@@ -396,33 +389,32 @@ export class FeishuConnector {
|
|
|
396
389
|
catch (err) {
|
|
397
390
|
const code = err.code;
|
|
398
391
|
if (code === 'ENOENT') {
|
|
399
|
-
throw new Error(`Cannot access
|
|
392
|
+
throw new Error(`Cannot access ${noun}: file not found (${filePath})`, { cause: err });
|
|
400
393
|
}
|
|
401
|
-
throw new Error(`Cannot access
|
|
394
|
+
throw new Error(`Cannot access ${noun}: ${err.message}`, { cause: err });
|
|
402
395
|
}
|
|
403
|
-
// Check file size limit (30MB, aligned with Feishu im/v1/files API)
|
|
404
396
|
if (stat.size > MAX_FILE_UPLOAD_SIZE) {
|
|
405
|
-
|
|
397
|
+
const nounCap = noun.charAt(0).toUpperCase() + noun.slice(1);
|
|
398
|
+
throw new Error(`${nounCap} too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
|
|
406
399
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
|
|
403
|
+
* → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
|
|
404
|
+
* 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
|
|
405
|
+
* (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
|
|
406
|
+
* 标签('sendFile' | 'sendImage')。
|
|
407
|
+
*/
|
|
408
|
+
async uploadAndSendMedia(chatId, filePath, label, uploadUrl, buildForm, keyField, msgType) {
|
|
411
409
|
let fileStream = null;
|
|
412
410
|
try {
|
|
413
|
-
// P2-18: tenant_access_token cache
|
|
414
|
-
// (token is valid ~2h); validate data.code before trusting the token.
|
|
411
|
+
// P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
|
|
415
412
|
const accessToken = await this.getTenantAccessToken();
|
|
416
|
-
// Upload file
|
|
417
|
-
const form = new FormData();
|
|
418
413
|
fileStream = fs.createReadStream(filePath);
|
|
419
|
-
form
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
// httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
|
|
424
|
-
// keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
|
|
425
|
-
const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
|
|
414
|
+
const form = buildForm(fileStream);
|
|
415
|
+
// P2-18: timeout 120s(大文件慢链路);httpsAgent: noKeepAlive 防止
|
|
416
|
+
// Bun 复用已被服务端 RST 的 keep-alive socket(ECONNRESET after ~30s)。
|
|
417
|
+
const uploadResp = await axios.post(uploadUrl, form, {
|
|
426
418
|
headers: {
|
|
427
419
|
...form.getHeaders(),
|
|
428
420
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -431,15 +423,14 @@ export class FeishuConnector {
|
|
|
431
423
|
httpsAgent: noKeepAliveAgent,
|
|
432
424
|
});
|
|
433
425
|
if (uploadResp.data.code !== 0) {
|
|
434
|
-
throw new Error(`
|
|
426
|
+
throw new Error(`Upload failed: ${uploadResp.data.msg}`);
|
|
435
427
|
}
|
|
436
|
-
const
|
|
437
|
-
//
|
|
438
|
-
// httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
|
|
428
|
+
const mediaKey = uploadResp.data.data[keyField];
|
|
429
|
+
// P2-18: timeout 30s;noKeepAlive 同上。
|
|
439
430
|
const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
|
|
440
431
|
receive_id: chatId,
|
|
441
|
-
msg_type:
|
|
442
|
-
content: JSON.stringify({
|
|
432
|
+
msg_type: msgType,
|
|
433
|
+
content: JSON.stringify({ [keyField]: mediaKey }),
|
|
443
434
|
}, {
|
|
444
435
|
headers: {
|
|
445
436
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -450,7 +441,7 @@ export class FeishuConnector {
|
|
|
450
441
|
httpsAgent: noKeepAliveAgent,
|
|
451
442
|
});
|
|
452
443
|
if (sendResp.data.code !== 0) {
|
|
453
|
-
throw new Error(`
|
|
444
|
+
throw new Error(`Media message failed: ${sendResp.data.msg}`);
|
|
454
445
|
}
|
|
455
446
|
return sendResp.data.data.message_id;
|
|
456
447
|
}
|
|
@@ -464,12 +455,39 @@ export class FeishuConnector {
|
|
|
464
455
|
/* already destroyed */
|
|
465
456
|
}
|
|
466
457
|
}
|
|
467
|
-
// Log the error but throw a formatted error to prevent unhandled rejection
|
|
468
458
|
const errorInfo = this.formatError(err);
|
|
469
|
-
getLogger().error(
|
|
470
|
-
throw new Error(
|
|
459
|
+
getLogger().error(`[feishu] ${label} failed:`, errorInfo);
|
|
460
|
+
throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
|
|
471
461
|
}
|
|
472
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Upload a file to Feishu and send it to the specified chat.
|
|
465
|
+
* Uses the im/v1/files upload API.
|
|
466
|
+
*/
|
|
467
|
+
async sendFile(chatId, filePath) {
|
|
468
|
+
this.checkUploadable(filePath, 'file');
|
|
469
|
+
return this.uploadAndSendMedia(chatId, filePath, 'sendFile', 'https://open.feishu.cn/open-apis/im/v1/files', (stream) => {
|
|
470
|
+
const form = new FormData();
|
|
471
|
+
form.append('file', stream);
|
|
472
|
+
form.append('file_name', displayName(filePath));
|
|
473
|
+
form.append('file_type', 'stream');
|
|
474
|
+
return form;
|
|
475
|
+
}, 'file_key', 'file');
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Upload a local image file to Feishu and send it as an image message
|
|
479
|
+
* (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
|
|
480
|
+
* scan-to-create QR code inline instead of as a downloadable file.
|
|
481
|
+
*/
|
|
482
|
+
async sendImage(chatId, filePath) {
|
|
483
|
+
this.checkUploadable(filePath, 'image');
|
|
484
|
+
return this.uploadAndSendMedia(chatId, filePath, 'sendImage', 'https://open.feishu.cn/open-apis/im/v1/images', (stream) => {
|
|
485
|
+
const form = new FormData();
|
|
486
|
+
form.append('image_type', 'message');
|
|
487
|
+
form.append('image', stream);
|
|
488
|
+
return form;
|
|
489
|
+
}, 'image_key', 'image');
|
|
490
|
+
}
|
|
473
491
|
/**
|
|
474
492
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
475
493
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ import { buildCardActionFullValue } from './router/card-action-payload.js';
|
|
|
15
15
|
import { Bridge } from './bridge/index.js';
|
|
16
16
|
import { initLogger, getLogger } from './logger/index.js';
|
|
17
17
|
import { StartupContactStore, sendStartupHello } from './startup-contact.js';
|
|
18
|
-
import { OwnerBinder,
|
|
18
|
+
import { OwnerBinder, formatBindGuidance } from './binder.js';
|
|
19
|
+
import { CloneSession } from './clone.js';
|
|
19
20
|
import { InstanceAlreadyRunningError, InstanceLock } from './instance-lock.js';
|
|
20
21
|
import { spawnReplacementBridge, waitForPreviousInstance } from './restart.js';
|
|
21
22
|
import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
|
|
@@ -26,6 +27,14 @@ import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/
|
|
|
26
27
|
import path from 'node:path';
|
|
27
28
|
import fs from 'node:fs';
|
|
28
29
|
import { silentlyUnlink } from './common/fs.js';
|
|
30
|
+
/**
|
|
31
|
+
* /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
|
|
32
|
+
* 共用同一判定,防止将来加别名时两处漂移。
|
|
33
|
+
*/
|
|
34
|
+
function isStopCommand(text) {
|
|
35
|
+
const t = text.trim().toLowerCase();
|
|
36
|
+
return t === '/stop' || t === '/t';
|
|
37
|
+
}
|
|
29
38
|
/** Send auto-resume card for a restored session */
|
|
30
39
|
async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false) {
|
|
31
40
|
if (!contact.userId)
|
|
@@ -280,7 +289,7 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
280
289
|
return { agentRegistry, sessionReaderRegistry };
|
|
281
290
|
}
|
|
282
291
|
/** Setup message and card action handlers. */
|
|
283
|
-
function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config) {
|
|
292
|
+
function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
|
|
284
293
|
// 入站媒体(图片/文件):先认证后下载(P1 review 修复)。
|
|
285
294
|
// connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
|
|
286
295
|
// 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
|
|
@@ -324,13 +333,10 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
324
333
|
})().catch((err) => logger.error('[media] inbound media download/save failed:', err));
|
|
325
334
|
});
|
|
326
335
|
connector.setMessageHandler((msg) => {
|
|
327
|
-
// 绑定/授权闸门:仅 owner 放行;非 owner
|
|
336
|
+
// 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息(任意内容)认领
|
|
328
337
|
const decision = binder.classify(msg.userId, msg.content, msg.chatId);
|
|
329
338
|
if (decision.kind === 'rejected')
|
|
330
339
|
return;
|
|
331
|
-
// 未绑定且 PIN 错误:完全静默丢弃(不回复、不提醒)
|
|
332
|
-
if (decision.kind === 'pin_wrong')
|
|
333
|
-
return;
|
|
334
340
|
if (decision.kind === 'bind_success') {
|
|
335
341
|
// First-run onboarding: set default cwd + send welcome + Help card.
|
|
336
342
|
// setCwd is synchronous (outside the async closure) so that the user's
|
|
@@ -385,12 +391,23 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
385
391
|
bridge.flushMediaNotifications(msg.userId, msg.chatId);
|
|
386
392
|
// Add Typing reaction to indicate "still alive"
|
|
387
393
|
void connector.addReaction(msg.messageId, 'Typing');
|
|
394
|
+
// 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
|
|
395
|
+
// 绕队列停止能力),不转发 coding agent、不进命令分发、不展开别名。
|
|
396
|
+
if (cloneSession.isActive() && !isStopCommand(msg.content)) {
|
|
397
|
+
void cloneSession
|
|
398
|
+
.handleMessage(msg.content, {
|
|
399
|
+
userId: msg.userId,
|
|
400
|
+
chatId: msg.chatId,
|
|
401
|
+
messageId: msg.messageId,
|
|
402
|
+
})
|
|
403
|
+
.catch((err) => logger.error('[clone] message handling failed:', err));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
388
406
|
// 别名展开:命令分发前对消息做一次 $name 展开(不递归)。
|
|
389
407
|
// `!` / `/` 开头的消息不会进入展开路径($PATH、$HOME 等 shell 变量不受影响),
|
|
390
408
|
// 未知 $xxx 原样透传;展开结果若以 `/` 开头会自然落入命令路径。
|
|
391
409
|
const content = router.expandAliasMessage(msg.content);
|
|
392
|
-
|
|
393
|
-
if (trimmedLower === '/stop' || trimmedLower === '/t') {
|
|
410
|
+
if (isStopCommand(content)) {
|
|
394
411
|
void (async () => {
|
|
395
412
|
const stopped = await bridge.interruptCurrentRun({
|
|
396
413
|
userId: msg.userId,
|
|
@@ -619,10 +636,10 @@ async function main() {
|
|
|
619
636
|
const connector = new FeishuConnector(config);
|
|
620
637
|
const startupContactStore = new StartupContactStore(path.join(configDir, 'startup-contact.json'));
|
|
621
638
|
const binder = new OwnerBinder(startupContactStore);
|
|
622
|
-
if (binder.
|
|
623
|
-
// 未绑定:控制台(stderr
|
|
624
|
-
console.error(
|
|
625
|
-
logger.info('[binder] awaiting first binding (
|
|
639
|
+
if (!binder.isBound()) {
|
|
640
|
+
// 未绑定:控制台(stderr)引导任意消息绑定。守护模式下被 watchdog 重定向到日志
|
|
641
|
+
console.error(formatBindGuidance());
|
|
642
|
+
logger.info('[binder] awaiting first binding (any first private message binds)');
|
|
626
643
|
}
|
|
627
644
|
else {
|
|
628
645
|
logger.info(`[binder] bound to owner openId=${binder.boundOpenId()}`);
|
|
@@ -637,6 +654,11 @@ async function main() {
|
|
|
637
654
|
agentRegistry,
|
|
638
655
|
sessionReaderRegistry,
|
|
639
656
|
});
|
|
657
|
+
const cloneSession = new CloneSession({
|
|
658
|
+
connector,
|
|
659
|
+
configPath: path.join(configDir, 'config.yaml'),
|
|
660
|
+
configDir,
|
|
661
|
+
});
|
|
640
662
|
const router = new CommandRouter({
|
|
641
663
|
sessionStore,
|
|
642
664
|
bridge,
|
|
@@ -648,8 +670,9 @@ async function main() {
|
|
|
648
670
|
sessionReaderRegistry,
|
|
649
671
|
devMode: cliArgs.dev,
|
|
650
672
|
updateCachePath: path.join(configDir, 'update-cache.json'),
|
|
673
|
+
cloneSession,
|
|
651
674
|
});
|
|
652
|
-
setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config);
|
|
675
|
+
setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession);
|
|
653
676
|
try {
|
|
654
677
|
await connector.connect();
|
|
655
678
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { LaunchSpec } from './types.js';
|
|
2
2
|
export type { LaunchSpec } from './types.js';
|
|
3
|
+
/** 判断一个已命中的 bash.exe 是否只是 WSL 启动器(而非 Git Bash)。 */
|
|
4
|
+
export declare function isWslBashLauncher(file: string): boolean;
|
|
3
5
|
export interface ResolveOptions {
|
|
4
6
|
platform?: NodeJS.Platform;
|
|
5
7
|
/** PATH 环境变量注入(测试用);默认 process.env.PATH */
|
package/dist/platform/command.js
CHANGED
|
@@ -18,6 +18,33 @@ import fs from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { currentPlatform, isWin32 } from './select.js';
|
|
20
20
|
const DEFAULT_PATHEXT = ['.exe', '.bat', '.cmd'];
|
|
21
|
+
/**
|
|
22
|
+
* win32 的「假 bash」:`%SystemRoot%\system32\bash.exe`(SysWOW64 同理)不是
|
|
23
|
+
* Git Bash,而是 WSL 启动器(wsl.exe 的 bash 别名)。PATH 里 system32 通常
|
|
24
|
+
* 排在 Git\cmd 之前,按目录顺序命中它会把整条 `!` 命令丢进 WSL 发行版执行:
|
|
25
|
+
* 工作目录变成 /mnt/d/...、node 变成 WSL 里装的那个版本。实测本机 WSL node
|
|
26
|
+
* 为 v12,加载 dist/index.js 的 ES2022 语法(?? / ?.)直接
|
|
27
|
+
* `SyntaxError: Unexpected token '?'`,`!lark-remote -v` 因此失败。
|
|
28
|
+
*
|
|
29
|
+
* 因此解析 `bash` 时必须跳过它,继续在 PATH 中找真正的 Git Bash。
|
|
30
|
+
*
|
|
31
|
+
* 判定只看两层目录名(`<...>\system32\bash.exe`):Git Bash 装在
|
|
32
|
+
* `<Git>\bin` / `<Git>\usr\bin`,不会叫 system32;反过来把真正的 Git Bash
|
|
33
|
+
* 装在名为 system32 的目录里也可以忽略。
|
|
34
|
+
*/
|
|
35
|
+
const WSL_BASH_PARENT_DIRS = ['system32', 'syswow64'];
|
|
36
|
+
/** 判断一个已命中的 bash.exe 是否只是 WSL 启动器(而非 Git Bash)。 */
|
|
37
|
+
export function isWslBashLauncher(file) {
|
|
38
|
+
const normalized = file.replaceAll('/', '\\').toLowerCase();
|
|
39
|
+
if (path.win32.basename(normalized) !== 'bash.exe')
|
|
40
|
+
return false;
|
|
41
|
+
return WSL_BASH_PARENT_DIRS.includes(path.win32.basename(path.win32.dirname(normalized)));
|
|
42
|
+
}
|
|
43
|
+
/** 查询目标是 bash(`bash` 或显式 `bash.exe`)时才走上面的特例。 */
|
|
44
|
+
function isBashQuery(name) {
|
|
45
|
+
const lower = name.toLowerCase();
|
|
46
|
+
return lower === 'bash' || lower === 'bash.exe';
|
|
47
|
+
}
|
|
21
48
|
/**
|
|
22
49
|
* 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——bridge 运行期间
|
|
23
50
|
* 用户新装/卸载 agent(典型场景:按 /config 不可用提示去安装)后,最迟
|
|
@@ -56,17 +83,42 @@ function existsWin32(file) {
|
|
|
56
83
|
return posixForm;
|
|
57
84
|
return null;
|
|
58
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Git for Windows 只把 `Git\cmd` 放进 PATH(里面没有 bash.exe),真正的
|
|
88
|
+
* bash 在 `Git\bin\bash.exe` / `Git\usr\bin\bash.exe`。PATH 里只暴露 cmd
|
|
89
|
+
* 目录时,从 cmd 反推安装根再找 bash。
|
|
90
|
+
*/
|
|
91
|
+
function resolveBashFromGitCmdDir(pathEnv) {
|
|
92
|
+
for (const dir of listPathDirs(pathEnv, 'win32')) {
|
|
93
|
+
if (path.win32.basename(dir).toLowerCase() !== 'cmd')
|
|
94
|
+
continue;
|
|
95
|
+
const root = path.win32.dirname(dir);
|
|
96
|
+
for (const rel of ['bin\\bash.exe', 'usr\\bin\\bash.exe']) {
|
|
97
|
+
const found = existsWin32(path.win32.join(root, rel));
|
|
98
|
+
if (found)
|
|
99
|
+
return { kind: 'direct', file: found };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
59
104
|
function resolveWin32(name, pathEnv, pathExt) {
|
|
60
105
|
const hasExtension = /\.[A-Za-z0-9]+$/.test(name);
|
|
106
|
+
const bashQuery = isBashQuery(name);
|
|
61
107
|
for (const dir of listPathDirs(pathEnv, 'win32')) {
|
|
62
108
|
const candidates = hasExtension ? [name] : pathExt.map((ext) => name + ext);
|
|
63
109
|
for (const candidate of candidates) {
|
|
64
110
|
const found = existsWin32(path.win32.join(dir, candidate));
|
|
111
|
+
if (!found)
|
|
112
|
+
continue;
|
|
113
|
+
// WSL 启动器不是 Git Bash:跳过,继续找后面的 PATH 目录
|
|
114
|
+
if (bashQuery && isWslBashLauncher(found))
|
|
115
|
+
continue;
|
|
65
116
|
// .cmd/.bat 垫片命中即报告垫片路径本身;执行语义由 cross-spawn 负责
|
|
66
|
-
|
|
67
|
-
return { kind: 'direct', file: found };
|
|
117
|
+
return { kind: 'direct', file: found };
|
|
68
118
|
}
|
|
69
119
|
}
|
|
120
|
+
if (bashQuery)
|
|
121
|
+
return resolveBashFromGitCmdDir(pathEnv);
|
|
70
122
|
return null;
|
|
71
123
|
}
|
|
72
124
|
function resolvePosix(name, pathEnv) {
|
package/dist/restart.d.ts
CHANGED
|
@@ -8,6 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/** Env var telling a replacement child which parent pid to wait for before acquiring the instance lock. */
|
|
10
10
|
export declare const RESTART_WAIT_PID_ENV = "LARK_REMOTE_RESTART_WAIT_PID";
|
|
11
|
+
/**
|
|
12
|
+
* Spawn a detached bridge process (shared by /restart replacement and /clone
|
|
13
|
+
* new-instance) whose early output — before the child's own file logger
|
|
14
|
+
* initializes — lands on logFilePath. Returns the child pid, or null when
|
|
15
|
+
* spawn failed synchronously / no pid; filesystem errors (unwritable log
|
|
16
|
+
* dir) propagate. Caller picks throw-vs-degrade semantics.
|
|
17
|
+
*
|
|
18
|
+
* Two disciplines live here so every caller inherits them for free:
|
|
19
|
+
* - windowsHide: win32 detached children would flash a console window (§3.7);
|
|
20
|
+
* - the 'error' handler is attached BEFORE the pid check — a failed spawn
|
|
21
|
+
* emits 'error' asynchronously (ENOENT/EACCES), and the synchronous pid
|
|
22
|
+
* check throws/returns first, so attaching after would leave the 'error'
|
|
23
|
+
* event unhandled → uncaughtException kills the parent.
|
|
24
|
+
*/
|
|
25
|
+
export declare function spawnDetachedBridge(args: string[], logFilePath: string, opts?: {
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
}): number | null;
|
|
11
28
|
/**
|
|
12
29
|
* Spawn a detached replacement bridge with the same executable and argv
|
|
13
30
|
* (including --config-dir) and return its pid. The caller is expected to
|
package/dist/restart.js
CHANGED
|
@@ -15,6 +15,41 @@ import { sleep } from './common/sleep.js';
|
|
|
15
15
|
export const RESTART_WAIT_PID_ENV = 'LARK_REMOTE_RESTART_WAIT_PID';
|
|
16
16
|
const WAIT_TIMEOUT_MS = 20_000;
|
|
17
17
|
const POLL_MS = 100;
|
|
18
|
+
/**
|
|
19
|
+
* Spawn a detached bridge process (shared by /restart replacement and /clone
|
|
20
|
+
* new-instance) whose early output — before the child's own file logger
|
|
21
|
+
* initializes — lands on logFilePath. Returns the child pid, or null when
|
|
22
|
+
* spawn failed synchronously / no pid; filesystem errors (unwritable log
|
|
23
|
+
* dir) propagate. Caller picks throw-vs-degrade semantics.
|
|
24
|
+
*
|
|
25
|
+
* Two disciplines live here so every caller inherits them for free:
|
|
26
|
+
* - windowsHide: win32 detached children would flash a console window (§3.7);
|
|
27
|
+
* - the 'error' handler is attached BEFORE the pid check — a failed spawn
|
|
28
|
+
* emits 'error' asynchronously (ENOENT/EACCES), and the synchronous pid
|
|
29
|
+
* check throws/returns first, so attaching after would leave the 'error'
|
|
30
|
+
* event unhandled → uncaughtException kills the parent.
|
|
31
|
+
*/
|
|
32
|
+
export function spawnDetachedBridge(args, logFilePath, opts) {
|
|
33
|
+
fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
|
|
34
|
+
const out = fs.openSync(logFilePath, 'a');
|
|
35
|
+
try {
|
|
36
|
+
const child = spawnProcess(process.execPath, args, {
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
env: opts?.env ?? process.env,
|
|
39
|
+
detached: true,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
stdio: ['ignore', out, out],
|
|
42
|
+
});
|
|
43
|
+
child.on('error', () => { });
|
|
44
|
+
if (child.pid === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
child.unref();
|
|
47
|
+
return child.pid;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
fs.closeSync(out);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
18
53
|
/**
|
|
19
54
|
* Spawn a detached replacement bridge with the same executable and argv
|
|
20
55
|
* (including --config-dir) and return its pid. The caller is expected to
|
|
@@ -22,29 +57,13 @@ const POLL_MS = 100;
|
|
|
22
57
|
* startup failures (before the file logger initializes) are not lost.
|
|
23
58
|
*/
|
|
24
59
|
export function spawnReplacementBridge(logsDir) {
|
|
25
|
-
|
|
26
|
-
const out = fs.openSync(path.join(logsDir, 'restart-child.log'), 'a');
|
|
27
|
-
const child = spawnProcess(process.execPath, process.argv.slice(1), {
|
|
28
|
-
cwd: process.cwd(),
|
|
60
|
+
const pid = spawnDetachedBridge(process.argv.slice(1), path.join(logsDir, 'restart-child.log'), {
|
|
29
61
|
env: { ...process.env, [RESTART_WAIT_PID_ENV]: String(process.pid) },
|
|
30
|
-
detached: true,
|
|
31
|
-
// win32:detached 继任者默认会闪控制台窗口(§3.7)
|
|
32
|
-
windowsHide: true,
|
|
33
|
-
stdio: ['ignore', out, out],
|
|
34
62
|
});
|
|
35
|
-
|
|
36
|
-
// exiting parent via an unhandled 'error' event. Attach BEFORE the pid
|
|
37
|
-
// check — a failed spawn emits 'error' asynchronously (ENOENT/EACCES),
|
|
38
|
-
// and the pid check below throws synchronously first, so attaching after
|
|
39
|
-
// the throw would leave the 'error' event unhandled → uncaughtException
|
|
40
|
-
// → the old bridge exits despite having promised to stay alive.
|
|
41
|
-
child.on('error', () => { });
|
|
42
|
-
fs.closeSync(out);
|
|
43
|
-
if (child.pid === undefined) {
|
|
63
|
+
if (pid === null) {
|
|
44
64
|
throw new Error('spawn replacement bridge failed: no pid');
|
|
45
65
|
}
|
|
46
|
-
|
|
47
|
-
return child.pid;
|
|
66
|
+
return pid;
|
|
48
67
|
}
|
|
49
68
|
/**
|
|
50
69
|
* On startup, if this process was spawned as a restart replacement, wait for
|
package/dist/router/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SessionStore, SessionReaderRegistry } from '../session/index.js';
|
|
|
2
2
|
import type { AppConfig } from '../config/index.js';
|
|
3
3
|
import type { Bridge } from '../bridge/index.js';
|
|
4
4
|
import type { AgentBinding } from '../bridge/queue-manager.js';
|
|
5
|
+
import type { CloneSession } from '../clone.js';
|
|
5
6
|
import { type VersionCheckResult, type InstallResult } from '../update/index.js';
|
|
6
7
|
interface CommandContext {
|
|
7
8
|
userId: string;
|
|
@@ -125,6 +126,8 @@ export declare class CommandRouter {
|
|
|
125
126
|
private devMode;
|
|
126
127
|
/** Cache file for version checks (<configDir>/update-cache.json). */
|
|
127
128
|
private updateCachePath?;
|
|
129
|
+
/** 复制分身状态机(/clone 入口 + 活跃期消息拦截)。 */
|
|
130
|
+
private cloneSession?;
|
|
128
131
|
/** Injected update functions (for testability; defaults to real implementation). */
|
|
129
132
|
private updateFns;
|
|
130
133
|
/** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
|
|
@@ -177,6 +180,8 @@ export declare class CommandRouter {
|
|
|
177
180
|
devMode?: boolean;
|
|
178
181
|
/** Version check cache path (<configDir>/update-cache.json). */
|
|
179
182
|
updateCachePath?: string;
|
|
183
|
+
/** 复制分身状态机(/clone;index.ts 构造注入,未注入时命令回报不可用)。 */
|
|
184
|
+
cloneSession?: CloneSession;
|
|
180
185
|
/** Override update functions for testability. */
|
|
181
186
|
updateFns?: {
|
|
182
187
|
checkLatestVersion?: (opts?: {
|
|
@@ -455,6 +460,11 @@ export declare class CommandRouter {
|
|
|
455
460
|
* Flow: check version → if newer, install → report success (no auto-restart).
|
|
456
461
|
*/
|
|
457
462
|
private cmdUpdate;
|
|
463
|
+
/**
|
|
464
|
+
* /clone [name] — 复制分身:进入 clone 状态机(发二维码、等扫码、自动绑定)。
|
|
465
|
+
* 全程由 CloneSession 自己经 connector 发消息(含二维码图片),故返回 null。
|
|
466
|
+
*/
|
|
467
|
+
private cmdClone;
|
|
458
468
|
private cmdNew;
|
|
459
469
|
private cmdCd;
|
|
460
470
|
private readSessionDisplayState;
|
|
@@ -548,7 +558,6 @@ export declare class CommandRouter {
|
|
|
548
558
|
* Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
|
|
549
559
|
*/
|
|
550
560
|
private buildActiveCardFromMemory;
|
|
551
|
-
private cmdReconnect;
|
|
552
561
|
private cmdConfig;
|
|
553
562
|
/**
|
|
554
563
|
* DSH /config 卡片构建前异步预取模型/预设目录(§4.2 动态清单)。
|