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/connector/index.js
CHANGED
|
@@ -378,11 +378,10 @@ export class FeishuConnector {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
|
|
382
|
+
* im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
|
|
383
383
|
*/
|
|
384
|
-
|
|
385
|
-
// Check file existence first with proper error handling
|
|
384
|
+
checkUploadable(filePath, noun) {
|
|
386
385
|
let stat;
|
|
387
386
|
try {
|
|
388
387
|
stat = fs.statSync(filePath);
|
|
@@ -390,33 +389,32 @@ export class FeishuConnector {
|
|
|
390
389
|
catch (err) {
|
|
391
390
|
const code = err.code;
|
|
392
391
|
if (code === 'ENOENT') {
|
|
393
|
-
throw new Error(`Cannot access
|
|
392
|
+
throw new Error(`Cannot access ${noun}: file not found (${filePath})`, { cause: err });
|
|
394
393
|
}
|
|
395
|
-
throw new Error(`Cannot access
|
|
394
|
+
throw new Error(`Cannot access ${noun}: ${err.message}`, { cause: err });
|
|
396
395
|
}
|
|
397
|
-
// Check file size limit (30MB, aligned with Feishu im/v1/files API)
|
|
398
396
|
if (stat.size > MAX_FILE_UPLOAD_SIZE) {
|
|
399
|
-
|
|
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`);
|
|
400
399
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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) {
|
|
405
409
|
let fileStream = null;
|
|
406
410
|
try {
|
|
407
|
-
// P2-18: tenant_access_token cache
|
|
408
|
-
// (token is valid ~2h); validate data.code before trusting the token.
|
|
411
|
+
// P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
|
|
409
412
|
const accessToken = await this.getTenantAccessToken();
|
|
410
|
-
// Upload file
|
|
411
|
-
const form = new FormData();
|
|
412
413
|
fileStream = fs.createReadStream(filePath);
|
|
413
|
-
form
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
|
|
418
|
-
// keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
|
|
419
|
-
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, {
|
|
420
418
|
headers: {
|
|
421
419
|
...form.getHeaders(),
|
|
422
420
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -425,15 +423,14 @@ export class FeishuConnector {
|
|
|
425
423
|
httpsAgent: noKeepAliveAgent,
|
|
426
424
|
});
|
|
427
425
|
if (uploadResp.data.code !== 0) {
|
|
428
|
-
throw new Error(`
|
|
426
|
+
throw new Error(`Upload failed: ${uploadResp.data.msg}`);
|
|
429
427
|
}
|
|
430
|
-
const
|
|
431
|
-
//
|
|
432
|
-
// httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
|
|
428
|
+
const mediaKey = uploadResp.data.data[keyField];
|
|
429
|
+
// P2-18: timeout 30s;noKeepAlive 同上。
|
|
433
430
|
const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
|
|
434
431
|
receive_id: chatId,
|
|
435
|
-
msg_type:
|
|
436
|
-
content: JSON.stringify({
|
|
432
|
+
msg_type: msgType,
|
|
433
|
+
content: JSON.stringify({ [keyField]: mediaKey }),
|
|
437
434
|
}, {
|
|
438
435
|
headers: {
|
|
439
436
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -444,7 +441,7 @@ export class FeishuConnector {
|
|
|
444
441
|
httpsAgent: noKeepAliveAgent,
|
|
445
442
|
});
|
|
446
443
|
if (sendResp.data.code !== 0) {
|
|
447
|
-
throw new Error(`
|
|
444
|
+
throw new Error(`Media message failed: ${sendResp.data.msg}`);
|
|
448
445
|
}
|
|
449
446
|
return sendResp.data.data.message_id;
|
|
450
447
|
}
|
|
@@ -458,12 +455,39 @@ export class FeishuConnector {
|
|
|
458
455
|
/* already destroyed */
|
|
459
456
|
}
|
|
460
457
|
}
|
|
461
|
-
// Log the error but throw a formatted error to prevent unhandled rejection
|
|
462
458
|
const errorInfo = this.formatError(err);
|
|
463
|
-
getLogger().error(
|
|
464
|
-
throw new Error(
|
|
459
|
+
getLogger().error(`[feishu] ${label} failed:`, errorInfo);
|
|
460
|
+
throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
|
|
465
461
|
}
|
|
466
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
|
+
}
|
|
467
491
|
/**
|
|
468
492
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
469
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
|
}
|
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;
|
package/dist/router/index.js
CHANGED
|
@@ -347,6 +347,8 @@ export class CommandRouter {
|
|
|
347
347
|
devMode;
|
|
348
348
|
/** Cache file for version checks (<configDir>/update-cache.json). */
|
|
349
349
|
updateCachePath;
|
|
350
|
+
/** 复制分身状态机(/clone 入口 + 活跃期消息拦截)。 */
|
|
351
|
+
cloneSession;
|
|
350
352
|
/** Injected update functions (for testability; defaults to real implementation). */
|
|
351
353
|
updateFns;
|
|
352
354
|
/** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
|
|
@@ -400,6 +402,7 @@ export class CommandRouter {
|
|
|
400
402
|
this.sessionReaderRegistry = opts.sessionReaderRegistry;
|
|
401
403
|
this.devMode = opts.devMode ?? false;
|
|
402
404
|
this.updateCachePath = opts.updateCachePath;
|
|
405
|
+
this.cloneSession = opts.cloneSession;
|
|
403
406
|
this.updateFns = {
|
|
404
407
|
checkLatestVersion: opts.updateFns?.checkLatestVersion ?? defaultCheckLatestVersion,
|
|
405
408
|
isNewer: opts.updateFns?.isNewer ?? defaultIsNewer,
|
|
@@ -421,6 +424,13 @@ export class CommandRouter {
|
|
|
421
424
|
const startsWithBang = trimmed.startsWith('!');
|
|
422
425
|
const startsWithSlash = trimmed.startsWith('/');
|
|
423
426
|
getLogger().info(`[router] handle message="${message.slice(0, 50)}..." trimmed="${trimmed.slice(0, 50)}..." startsWithSlash=${startsWithSlash} startsWithBang=${startsWithBang}`);
|
|
427
|
+
// 复制分身流程活跃期:一切消息(含命令)交给 clone 状态机处理,
|
|
428
|
+
// 不转发 coding agent、不进命令分发(index.ts 已在入队前拦截,这里
|
|
429
|
+
// 兜住 order.exec 等其他 router.handle 入口)。
|
|
430
|
+
if (this.cloneSession?.isActive()) {
|
|
431
|
+
await this.cloneSession.handleMessage(trimmed, ctx);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
424
434
|
if (startsWithSlash) {
|
|
425
435
|
const result = await this.executeCommand(trimmed, ctx);
|
|
426
436
|
if (result) {
|
|
@@ -2001,6 +2011,8 @@ export class CommandRouter {
|
|
|
2001
2011
|
case 'update':
|
|
2002
2012
|
case 'u':
|
|
2003
2013
|
return await this.cmdUpdate(args, ctx);
|
|
2014
|
+
case 'clone':
|
|
2015
|
+
return await this.cmdClone(args, ctx);
|
|
2004
2016
|
default:
|
|
2005
2017
|
return { text: `未知命令 /${cmd},输入 /help 查看可用命令` };
|
|
2006
2018
|
}
|
|
@@ -2021,6 +2033,11 @@ export class CommandRouter {
|
|
|
2021
2033
|
{ cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
|
|
2022
2034
|
{ cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
|
|
2023
2035
|
{ cmd: 'update', label: '/update /u', desc: '检查并升级到最新版本' },
|
|
2036
|
+
{
|
|
2037
|
+
cmd: 'clone',
|
|
2038
|
+
label: '/clone',
|
|
2039
|
+
desc: '复制分身:克隆配置并扫码创建新应用',
|
|
2040
|
+
},
|
|
2024
2041
|
{ cmd: 'status', label: '/status /s', desc: '显示当前状态' },
|
|
2025
2042
|
].sort((a, b) => a.label.length - b.label.length || a.cmd.localeCompare(b.cmd));
|
|
2026
2043
|
// 文本组(需带参数,不宜用按钮;以纯文本行展示)
|
|
@@ -2271,6 +2288,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2271
2288
|
// 4. Report success (no auto-restart — user decides when to /restart)
|
|
2272
2289
|
return { text: `✅ 升级成功 (${current} → ${latest}),发送 /restart 重启后生效` };
|
|
2273
2290
|
}
|
|
2291
|
+
/**
|
|
2292
|
+
* /clone [name] — 复制分身:进入 clone 状态机(发二维码、等扫码、自动绑定)。
|
|
2293
|
+
* 全程由 CloneSession 自己经 connector 发消息(含二维码图片),故返回 null。
|
|
2294
|
+
*/
|
|
2295
|
+
async cmdClone(args, ctx) {
|
|
2296
|
+
if (!this.cloneSession) {
|
|
2297
|
+
return { text: '当前环境未启用创建分身功能' };
|
|
2298
|
+
}
|
|
2299
|
+
await this.cloneSession.start(args[0], ctx);
|
|
2300
|
+
return null;
|
|
2301
|
+
}
|
|
2274
2302
|
cmdNew(ctx) {
|
|
2275
2303
|
// 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
|
|
2276
2304
|
// 设置工作目录",但用户的 workspace 还在。2026-06-21 复盘:用户
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test:opencode-full": "vitest run --project agent-opencode --project infra-runner --project infra-session",
|
|
59
59
|
"test:pi-full": "vitest run --project agent-pi --project infra-runner --project infra-session",
|
|
60
60
|
"test:kimi-full": "vitest run --project agent-kimi --project infra-runner --project infra-session",
|
|
61
|
-
"test:live": "vitest run --project live",
|
|
61
|
+
"test:live": "node node_modules/vitest/vitest.mjs run --project live",
|
|
62
62
|
"prepublishOnly": "npm run typecheck && vitest run && npm run build",
|
|
63
63
|
"prepack": "node -e \"const fs=require('fs');if(!fs.existsSync('dist/cli.js')){console.error('dist/ missing — run bun run build first');process.exit(1)}\""
|
|
64
64
|
},
|