lark-remote 0.5.12 → 0.5.13
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 +13 -2
- package/README.en.md +1 -14
- package/README.md +1 -14
- package/dist/bridge/approval-coordinator.d.ts +1 -0
- package/dist/bridge/approval-coordinator.js +12 -24
- package/dist/bridge/index.d.ts +7 -0
- package/dist/bridge/index.js +77 -227
- package/dist/bridge/run-approval.d.ts +15 -0
- package/dist/bridge/run-approval.js +41 -0
- package/dist/bridge/run-usage.d.ts +46 -0
- package/dist/bridge/run-usage.js +96 -0
- package/dist/bridge/session-writeback.d.ts +18 -0
- package/dist/bridge/session-writeback.js +36 -0
- package/dist/card/bash-card-session.js +2 -5
- package/dist/card/card-session.d.ts +2 -0
- package/dist/card/card-session.js +20 -22
- package/dist/card/card-shared.d.ts +4 -4
- package/dist/card/card-shared.js +3 -9
- package/dist/card/collapsible.js +3 -2
- package/dist/card/final-answer-overflow.d.ts +7 -0
- package/dist/card/final-answer-overflow.js +46 -0
- package/dist/card/run-card-session.js +2 -7
- package/dist/card/run-renderer.d.ts +14 -0
- package/dist/card/run-renderer.js +119 -1
- package/dist/card/run-state.d.ts +12 -1
- package/dist/card/run-state.js +73 -34
- package/dist/config/codex-config.js +4 -4
- package/dist/config/index.d.ts +4 -2
- package/dist/config/index.js +6 -4
- package/dist/inbound/turn-assembler.js +6 -0
- package/dist/index.js +15 -409
- package/dist/message-handlers.d.ts +26 -0
- package/dist/message-handlers.js +275 -0
- package/dist/router/card-action-payload.d.ts +1 -1
- package/dist/router/card-action-payload.js +1 -1
- package/dist/router/commands/ls.d.ts +9 -0
- package/dist/router/commands/ls.js +243 -0
- package/dist/router/commands/order.d.ts +12 -0
- package/dist/router/commands/order.js +178 -0
- package/dist/router/commands/resume.d.ts +47 -0
- package/dist/router/commands/resume.js +246 -0
- package/dist/router/commands/ws.d.ts +15 -0
- package/dist/router/commands/ws.js +189 -0
- package/dist/router/config/claude.js +3 -3
- package/dist/router/config/codex.js +2 -6
- package/dist/router/config/common/turn-idle.d.ts +2 -0
- package/dist/router/config/common/turn-idle.js +9 -0
- package/dist/router/config/kimi.js +2 -6
- package/dist/router/config/opencode.js +2 -6
- package/dist/router/index.d.ts +14 -22
- package/dist/router/index.js +70 -1152
- package/dist/router/paging.d.ts +13 -0
- package/dist/router/paging.js +20 -0
- package/dist/router/path-validation.d.ts +27 -0
- package/dist/router/path-validation.js +45 -0
- package/dist/runner/claude/runner.d.ts +3 -4
- package/dist/runner/claude/runner.js +4 -5
- package/dist/runner/claude/session.js +1 -1
- package/dist/runner/codex/app-server/translator.d.ts +2 -0
- package/dist/runner/codex/app-server/translator.js +65 -9
- package/dist/runner/common/acp/base-translator.d.ts +3 -8
- package/dist/runner/common/acp/base-translator.js +16 -7
- package/dist/runner/common/spawning-runner.js +10 -4
- package/dist/runner/common/turn-started.d.ts +2 -0
- package/dist/runner/common/turn-started.js +10 -0
- package/dist/runner/dsh/auth.js +2 -3
- package/dist/runner/dsh/client.d.ts +1 -6
- package/dist/runner/dsh/client.js +11 -165
- package/dist/runner/dsh/daemon.d.ts +0 -2
- package/dist/runner/dsh/daemon.js +1 -6
- package/dist/runner/dsh/errors.d.ts +5 -0
- package/dist/runner/dsh/errors.js +11 -0
- package/dist/runner/dsh/exit-cancel.d.ts +0 -2
- package/dist/runner/dsh/exit-cancel.js +1 -1
- package/dist/runner/dsh/home.d.ts +2 -0
- package/dist/runner/dsh/home.js +8 -0
- package/dist/runner/dsh/inflight.d.ts +0 -2
- package/dist/runner/dsh/inflight.js +1 -1
- package/dist/runner/dsh/mux-stream.d.ts +26 -0
- package/dist/runner/dsh/mux-stream.js +157 -0
- package/dist/runner/dsh/translator.d.ts +0 -10
- package/dist/runner/dsh/translator.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/translator.js +1 -12
- package/dist/runner/opencode/acp/translator.js +1 -12
- package/dist/runner/pi/rpc/translator.d.ts +2 -8
- package/dist/runner/pi/rpc/translator.js +2 -7
- package/dist/runner/registry.d.ts +0 -19
- package/dist/runner/registry.js +0 -32
- package/dist/runner/types.d.ts +17 -1
- package/dist/session/claude/session-index.js +4 -2
- package/dist/session/kimi/sessions.js +2 -2
- package/dist/session/opencode/sessions.js +2 -1
- package/dist/session/pi/sessions.js +3 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,18 +5,14 @@ import { parseCliArgs, resolveConfigDir, setConfigDir, printHelp, printVersion,
|
|
|
5
5
|
import { FeishuConnector } from './connector/index.js';
|
|
6
6
|
import { ClaudeRunner, CodexAppServerRunner, OpencodeAcpRunner, PiRpcRunner, KimiAcpRunner, DshRunner, } from './runner/index.js';
|
|
7
7
|
import { AgentRegistry } from './runner/registry.js';
|
|
8
|
-
import { AGENT_DISPLAY_NAMES, AGENT_KINDS } from './runner/types.js';
|
|
9
8
|
import { probeAllAgents } from './runner/probe.js';
|
|
10
9
|
import { warmCodexCatalogCache } from './config/codex-config.js';
|
|
11
10
|
import { SessionReaderRegistry, SessionStore } from './session/index.js';
|
|
12
11
|
import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessionReader, KimiSessionReader, DshSessionReader, } from './session/index.js';
|
|
13
|
-
import { CommandRouter
|
|
12
|
+
import { CommandRouter } from './router/index.js';
|
|
14
13
|
import { DshDaemon } from './runner/dsh/daemon.js';
|
|
15
14
|
import { DshClient } from './runner/dsh/client.js';
|
|
16
15
|
import { DshInflightStore, dshInflightPath, reconcileInflightDshRuns, } from './runner/dsh/inflight.js';
|
|
17
|
-
import { dispatchOrderExecForQueue } from './router/order-exec-dispatch.js';
|
|
18
|
-
import { buildCardActionFullValue } from './router/card-action-payload.js';
|
|
19
|
-
import { actionFeedbackText } from './router/card-action-feedback.js';
|
|
20
16
|
import { Bridge } from './bridge/index.js';
|
|
21
17
|
import { initLogger, getLogger } from './logger/index.js';
|
|
22
18
|
import { StartupContactStore, sendStartupHello } from './startup-contact.js';
|
|
@@ -29,30 +25,10 @@ import { startSleepBlocker } from './platform/sleep-blocker.js';
|
|
|
29
25
|
import { checkLatestVersion, compareVersions, runInstallLatest, formatUpdateHint, } from './update/index.js';
|
|
30
26
|
import { classifyRejection } from './error-classification.js';
|
|
31
27
|
import { WorkspaceStore } from './workspace/index.js';
|
|
32
|
-
import { InboundTurnAssembler } from './inbound/turn-assembler.js';
|
|
33
|
-
import { InboundAuthorizer } from './inbound/authorize.js';
|
|
34
|
-
import { stripPlaceholders } from './inbound/placeholder.js';
|
|
35
28
|
import { buildSessionHistoryCard, pickResumeCardEvents } from './router/card-helpers.js';
|
|
36
29
|
import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/card-shared.js';
|
|
37
|
-
import { sleep } from './common/sleep.js';
|
|
38
30
|
import path from 'node:path';
|
|
39
|
-
import
|
|
40
|
-
import { bestEffortUnlink } from './common/fs.js';
|
|
41
|
-
/**
|
|
42
|
-
* 会话变更类卡片动作执行前冲刷入站装配窗口的等待预算(ms)。flush 本身无下载
|
|
43
|
-
* 在途时瞬时完成(顺序保证完整);预算只挡「in-flight 媒体下载卡住」的场景
|
|
44
|
-
* (下载超时 5 分钟/资源)——SDK 把卡片动作放进与消息共享的 per-chat 串行
|
|
45
|
-
* lane 并 await 到底,无界等待会让整个 chat 停摆到下载超时。
|
|
46
|
-
*/
|
|
47
|
-
const SESSION_MUTATING_FLUSH_BUDGET_MS = 2_000;
|
|
48
|
-
/**
|
|
49
|
-
* /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
|
|
50
|
-
* 共用同一判定,防止将来加别名时两处漂移。
|
|
51
|
-
*/
|
|
52
|
-
function isStopCommand(text) {
|
|
53
|
-
const t = text.trim().toLowerCase();
|
|
54
|
-
return t === '/stop' || t === '/t';
|
|
55
|
-
}
|
|
31
|
+
import { setupMessageHandlers } from './message-handlers.js';
|
|
56
32
|
/** Send auto-resume card for a restored session */
|
|
57
33
|
async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false, stillRunning = false) {
|
|
58
34
|
if (!contact.userId)
|
|
@@ -325,12 +301,6 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
325
301
|
getLogger().warn(`[dsh] startup probe failed: ${error.message}`);
|
|
326
302
|
});
|
|
327
303
|
}
|
|
328
|
-
// 显示名单源在 runner/types.ts 的 AGENT_DISPLAY_NAMES(启动注册与回退共用)
|
|
329
|
-
for (const kind of AGENT_KINDS) {
|
|
330
|
-
agentRegistry.registerDisplayName(kind, AGENT_DISPLAY_NAMES[kind]);
|
|
331
|
-
}
|
|
332
|
-
// 设置全局 registry,供 agentDisplayName 等全局函数使用
|
|
333
|
-
AgentRegistry.setGlobalInstance(agentRegistry);
|
|
334
304
|
// Probe agent CLI availability (fire-and-forget, populates cache for /config card).
|
|
335
305
|
// Don't block startup — log unavailable agents as warnings.
|
|
336
306
|
probeAllAgents()
|
|
@@ -343,8 +313,8 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
343
313
|
getLogger().info('[probe] all agent CLIs available');
|
|
344
314
|
}
|
|
345
315
|
})
|
|
346
|
-
.catch(() => {
|
|
347
|
-
|
|
316
|
+
.catch((err) => {
|
|
317
|
+
getLogger().warn(`[probe] background probe failed: ${err.message}`);
|
|
348
318
|
});
|
|
349
319
|
// Warm the codex model catalog in the background so the first Codex /config
|
|
350
320
|
// card open reads a warm cache instead of blocking the event loop for up to
|
|
@@ -353,380 +323,6 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
353
323
|
warmCodexCatalogCache();
|
|
354
324
|
return { agentRegistry, sessionReaderRegistry };
|
|
355
325
|
}
|
|
356
|
-
/** Setup message and card action handlers. */
|
|
357
|
-
function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
|
|
358
|
-
// 入站统一装配器(2026-09-15):图/文任意顺序 → 同一个 turn、同一个 prompt,
|
|
359
|
-
// 附件路径统一注入 prompt;无文本纯附件只回执(决策 2)。
|
|
360
|
-
const assembler = new InboundTurnAssembler({
|
|
361
|
-
onCommit: (turn, prompt) => {
|
|
362
|
-
// 入队时刻快照 cwd + agent/session 绑定,语义与旧路径一致
|
|
363
|
-
// (排队期间 /cd、/config 不再导致语义漂移)。
|
|
364
|
-
const messageId = turn.messageIds[turn.messageIds.length - 1] ?? '';
|
|
365
|
-
// turnMessageIds:终态收尾要覆盖本轮每一条消息(逐条挂的 Typing 得逐条撤)
|
|
366
|
-
const ctx = {
|
|
367
|
-
userId: turn.userId,
|
|
368
|
-
chatId: turn.chatId,
|
|
369
|
-
messageId,
|
|
370
|
-
turnMessageIds: turn.messageIds,
|
|
371
|
-
};
|
|
372
|
-
let cwd = sessionStore.getCwd(turn.userId) ?? '';
|
|
373
|
-
if (!cwd && workspaceStore) {
|
|
374
|
-
// 兜底取「最近使用」而非插入序首位:WorkspaceStore.list() 按 Map 插入序
|
|
375
|
-
// 返回(touch 原地改时间戳不重排),[0] 恒为最早保存的别名——触发时会把
|
|
376
|
-
// 消息静默发到最旧工作区,与「最近使用」直觉相反。
|
|
377
|
-
const recent = [...workspaceStore.list()].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
378
|
-
if (recent.length > 0)
|
|
379
|
-
cwd = recent[0].path;
|
|
380
|
-
}
|
|
381
|
-
const binding = bridge.currentBinding(turn.userId);
|
|
382
|
-
bridge.enqueue(cwd, async () => {
|
|
383
|
-
// allowCommandPrefix: false —— prompt 已剥离占位符,绝不再做命令分发。
|
|
384
|
-
await router.handle(prompt, ctx, {
|
|
385
|
-
cwdOverride: cwd,
|
|
386
|
-
binding,
|
|
387
|
-
allowCommandPrefix: false,
|
|
388
|
-
});
|
|
389
|
-
}, {
|
|
390
|
-
taskMeta: {
|
|
391
|
-
userId: turn.userId,
|
|
392
|
-
chatId: turn.chatId,
|
|
393
|
-
messageId,
|
|
394
|
-
messagePreview: prompt.slice(0, 3000),
|
|
395
|
-
binding,
|
|
396
|
-
},
|
|
397
|
-
});
|
|
398
|
-
},
|
|
399
|
-
onReceipt: async (ctx, text) => {
|
|
400
|
-
await bridge.sendResult({ text }, ctx);
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
router.setInboundFlusher(() => assembler.flushAll('flush'));
|
|
404
|
-
// 统一的入站授权入口(文本 + 媒体共用):未绑定时,首条入站消息——无论
|
|
405
|
-
// 纯文本、纯媒体还是图文混合——都完成 owner 绑定并触发一次 onboarding。
|
|
406
|
-
// 媒体入口原先只做只读 isOwner 检查,新安装用户第一条消息是截图时既不绑定、
|
|
407
|
-
// 也不保存、也没有任何反馈。
|
|
408
|
-
const authorizer = new InboundAuthorizer({
|
|
409
|
-
binder,
|
|
410
|
-
onBind: (userId, chatId) => {
|
|
411
|
-
// First-run onboarding: set default cwd + send welcome + Help card.
|
|
412
|
-
// setCwd is synchronous (before returning the async send promise) so the
|
|
413
|
-
// user's next message — which will hit the `owner` branch now that
|
|
414
|
-
// startup-contact.json has been written — finds cwd already set.
|
|
415
|
-
// process.cwd() is the only sensible default: the user started
|
|
416
|
-
// lark-remote in the directory they want to work in.
|
|
417
|
-
// setCwd only when no cwd is set yet (e.g. fresh start after bind).
|
|
418
|
-
// Don't overwrite an existing cwd — the user may have already set one
|
|
419
|
-
// via /cd or a previous bind. Use realpath for canonical form (matching
|
|
420
|
-
// Claude JSONL cwd, e.g. /tmp → /private/tmp on macOS).
|
|
421
|
-
let resolvedCwd;
|
|
422
|
-
try {
|
|
423
|
-
resolvedCwd = fs.realpathSync(process.cwd());
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
resolvedCwd = process.cwd();
|
|
427
|
-
}
|
|
428
|
-
if (!sessionStore.getCwd(userId)) {
|
|
429
|
-
sessionStore.setCwd(userId, resolvedCwd);
|
|
430
|
-
}
|
|
431
|
-
return (async () => {
|
|
432
|
-
try {
|
|
433
|
-
// Message 1: bind confirmation + status (combined to avoid
|
|
434
|
-
// Feishu out-of-order delivery across three separate messages).
|
|
435
|
-
await connector.sendWithRetry(chatId, {
|
|
436
|
-
text: `✅ 已绑定到本账号,此后仅你可使用本应用\n` +
|
|
437
|
-
`📂 当前工作目录: \`${resolvedCwd}\`\n` +
|
|
438
|
-
`🤖 默认 Agent: ${agentDisplayName(config.defaultAgent)}\n` +
|
|
439
|
-
`💡 直接输入消息即可开始对话,或 /cd 切换目录`,
|
|
440
|
-
});
|
|
441
|
-
// Message 2: Help card.
|
|
442
|
-
// Use router.cmdHelp() + manual send (without replyTo) so the Help
|
|
443
|
-
// card appears as an independent message, not a reply to the PIN
|
|
444
|
-
// message. router.handle('/help') would set replyTo=msg.messageId
|
|
445
|
-
// (the PIN), causing the card to nest under the PIN in Feishu UI.
|
|
446
|
-
const helpResult = router.cmdHelp();
|
|
447
|
-
if (helpResult.card) {
|
|
448
|
-
await connector.sendWithRetry(chatId, { card: helpResult.card });
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
catch (err) {
|
|
452
|
-
logger.error('[binder] onboarding send failed:', err);
|
|
453
|
-
}
|
|
454
|
-
})();
|
|
455
|
-
},
|
|
456
|
-
});
|
|
457
|
-
// 入站媒体(图片/文件/视频/语音/表情):先认证后下载(修复)。
|
|
458
|
-
// connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
|
|
459
|
-
// 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
|
|
460
|
-
// 下载是异步的:立刻把在途 promise 交给装配器(`kind: 'media'`),
|
|
461
|
-
// 保证「先图后文」不会先提交一个只含文本的 turn。
|
|
462
|
-
connector.setInboundMediaDetectedHandler((msg) => {
|
|
463
|
-
// 与文本入口共用授权:首条纯媒体也在这里完成 owner 绑定(bind_success
|
|
464
|
-
// 继续走下载 + 装配,纯媒体最终由装配器发 no-text 回执)。
|
|
465
|
-
const authorization = authorizer.authorize(msg.userId, msg.chatId);
|
|
466
|
-
if (authorization === 'rejected') {
|
|
467
|
-
logger.warn(`[media] rejected inbound media from non-owner ${msg.userId}`);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
// clone 状态机活跃期:一切输入归 clone 流程、不落盘附件
|
|
471
|
-
// (inbound-unified-input-design.md §5.8;文本入口有同款闸门)。静默丢弃
|
|
472
|
-
// 而非发回执——装配器的「📎 已保存」回执会与 clone 引导话术冲突,诱导
|
|
473
|
-
// 用户「说一句话」触发 clone 的婉拒回复。
|
|
474
|
-
if (cloneSession.isActive()) {
|
|
475
|
-
logger.info(`[media] dropped inbound media during clone session ${msg.messageId}`);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
// 读 router.config(活引用):/config 保存后 setConfigValues 返回新对象,
|
|
479
|
-
// 启动时捕获的 config 参数会过期(修复)。
|
|
480
|
-
if (!router.config.inboundMedia.enabled) {
|
|
481
|
-
// 不静默:关闭时给 owner 明确反馈,避免发图后无任何反应。
|
|
482
|
-
// 不下载、不落盘,只进 rejected(随 turn 回执一起发出)。
|
|
483
|
-
assembler.ingest({
|
|
484
|
-
kind: 'rejected',
|
|
485
|
-
userId: msg.userId,
|
|
486
|
-
chatId: msg.chatId,
|
|
487
|
-
messageId: msg.messageId,
|
|
488
|
-
replyToMessageId: msg.replyToMessageId,
|
|
489
|
-
rawContentType: msg.rawContentType,
|
|
490
|
-
rejectedKind: 'file',
|
|
491
|
-
reason: '入站媒体保存已关闭(inboundMedia.enabled: false),未保存文件。' +
|
|
492
|
-
'如需自动保存图片/文件,请开启后重试',
|
|
493
|
-
});
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
const outcome = (async () => {
|
|
497
|
-
const payload = await connector.downloadInboundMedia(msg, {
|
|
498
|
-
maxFileSizeMb: router.config.inboundMedia.maxFileSizeMb,
|
|
499
|
-
});
|
|
500
|
-
try {
|
|
501
|
-
return await bridge.saveInboundMedia(payload);
|
|
502
|
-
}
|
|
503
|
-
catch (err) {
|
|
504
|
-
// save() 的正常路径会清理临时文件;这里兜底 save 进入 mkdir
|
|
505
|
-
// try 之前意外抛错的情况,避免 os.tmpdir 残留。
|
|
506
|
-
for (const item of payload.media) {
|
|
507
|
-
bestEffortUnlink(item.tempPath);
|
|
508
|
-
}
|
|
509
|
-
throw err;
|
|
510
|
-
}
|
|
511
|
-
})();
|
|
512
|
-
assembler.ingest({
|
|
513
|
-
kind: 'media',
|
|
514
|
-
userId: msg.userId,
|
|
515
|
-
chatId: msg.chatId,
|
|
516
|
-
messageId: msg.messageId,
|
|
517
|
-
replyToMessageId: msg.replyToMessageId,
|
|
518
|
-
rawContentType: msg.rawContentType,
|
|
519
|
-
outcome,
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
connector.setMessageHandler((msg) => {
|
|
523
|
-
// 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息认领。
|
|
524
|
-
// 与媒体入口共用 InboundAuthorizer:bind_success 的 onboarding 只跑一次
|
|
525
|
-
// (同一条 post 同时带图和文字时两个入口都会看到 bind_success)。
|
|
526
|
-
const decision = authorizer.authorize(msg.userId, msg.chatId);
|
|
527
|
-
if (decision === 'rejected')
|
|
528
|
-
return;
|
|
529
|
-
// 首条消息只用于完成绑定,不转发给 agent(与旧行为一致)。
|
|
530
|
-
if (decision === 'bind_success')
|
|
531
|
-
return;
|
|
532
|
-
// decision === 'owner':正常处理(不再每条覆盖 startup-contact)
|
|
533
|
-
logger.info(`message from ${msg.userId} (${msg.rawContentType}): ${msg.content.slice(0, 100)}`);
|
|
534
|
-
// Add Typing reaction to indicate "still alive"
|
|
535
|
-
void connector.addReaction(msg.messageId, 'Typing');
|
|
536
|
-
// 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
|
|
537
|
-
// 绕队列停止能力),不转发 coding agent、不进命令分发、不展开别名。
|
|
538
|
-
if (cloneSession.isActive() && !isStopCommand(msg.content)) {
|
|
539
|
-
void cloneSession
|
|
540
|
-
.handleMessage(msg.content, {
|
|
541
|
-
userId: msg.userId,
|
|
542
|
-
chatId: msg.chatId,
|
|
543
|
-
messageId: msg.messageId,
|
|
544
|
-
})
|
|
545
|
-
.catch((err) => logger.error('[clone] message handling failed:', err));
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
// 别名展开:命令分发前对消息做一次 $name 展开(不递归)。
|
|
549
|
-
// `!` / `/` 开头的消息不会进入展开路径($PATH、$HOME 等 shell 变量不受影响),
|
|
550
|
-
// 未知 $xxx 原样透传;展开结果若以 `/` 开头会自然落入命令路径。
|
|
551
|
-
const content = router.expandAliasMessage(msg.content);
|
|
552
|
-
if (isStopCommand(content)) {
|
|
553
|
-
void (async () => {
|
|
554
|
-
const interrupt = await bridge.interruptCurrentRunDetailed({
|
|
555
|
-
userId: msg.userId,
|
|
556
|
-
chatId: msg.chatId,
|
|
557
|
-
});
|
|
558
|
-
const ctx = { userId: msg.userId, chatId: msg.chatId, messageId: msg.messageId };
|
|
559
|
-
if (!interrupt.stopped) {
|
|
560
|
-
await bridge.sendResult({ text: '当前没有运行中的进程' }, ctx);
|
|
561
|
-
}
|
|
562
|
-
else if ((interrupt.stoppedCount ?? 1) > 1) {
|
|
563
|
-
// 并行 workspace 时 /stop 停全部,必须让用户看到数量。
|
|
564
|
-
await bridge.sendResult({ text: `✅ 已停止 ${interrupt.stoppedCount} 个运行中的任务` }, ctx);
|
|
565
|
-
}
|
|
566
|
-
})().catch((err) => logger.error('[control] /stop failed:', err));
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
// 先清洗,后判语义(§5.6):结构占位符永远不能触发命令分发。
|
|
570
|
-
// `` 的首字符恰好是 `!` —— 2026-09-15 事故就是这么进 shell 的。
|
|
571
|
-
const stripped = stripPlaceholders(content);
|
|
572
|
-
if (stripped.unknownTags.length > 0) {
|
|
573
|
-
logger.warn(`[inbound] unknown placeholder tags stripped: ${stripped.unknownTags.join(', ')} ` +
|
|
574
|
-
`(msg_type=${msg.rawContentType})`);
|
|
575
|
-
}
|
|
576
|
-
const commandEligible = msg.rawContentType === 'text' && stripped.kinds.length === 0 && stripped.clean !== '';
|
|
577
|
-
// 命令消息旁路装配窗口:/命令 立即执行,但必须先等窗口内已装配的内容
|
|
578
|
-
// (含在途媒体下载)提交完成。此前 `void flush()` 不等待,
|
|
579
|
-
// 有下载在途时命令会先于 commit 执行——发送在 /cd、/new 前的消息被绑定到
|
|
580
|
-
// 新 cwd/session,媒体也落到新目录。/stop 已在上面走快速路径,不受影响。
|
|
581
|
-
if (commandEligible && (stripped.clean.startsWith('/') || stripped.clean.startsWith('!'))) {
|
|
582
|
-
void (async () => {
|
|
583
|
-
await assembler.flush(msg.userId, msg.chatId, 'flush');
|
|
584
|
-
await router.handle(stripped.clean, {
|
|
585
|
-
userId: msg.userId,
|
|
586
|
-
chatId: msg.chatId,
|
|
587
|
-
messageId: msg.messageId,
|
|
588
|
-
}, { allowCommandPrefix: true });
|
|
589
|
-
})().catch((err) => logger.error('[control] command failed:', err));
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
// 普通消息:进装配器,等静默期窗口到期(下载落定)后合并成一个 turn。
|
|
593
|
-
assembler.ingest({
|
|
594
|
-
kind: 'text',
|
|
595
|
-
userId: msg.userId,
|
|
596
|
-
chatId: msg.chatId,
|
|
597
|
-
messageId: msg.messageId,
|
|
598
|
-
replyToMessageId: msg.replyToMessageId,
|
|
599
|
-
rawContentType: msg.rawContentType,
|
|
600
|
-
text: stripped.clean,
|
|
601
|
-
placeholders: stripped.kinds,
|
|
602
|
-
unknownTags: stripped.unknownTags,
|
|
603
|
-
});
|
|
604
|
-
});
|
|
605
|
-
connector.setCardActionHandler(async (action) => {
|
|
606
|
-
// 仅已绑定的 owner 可触发卡片操作;其余静默丢弃(计数 + debug)
|
|
607
|
-
if (!binder.isOwner(action.operator.openId)) {
|
|
608
|
-
binder.recordRejectedCardAction(action.operator.openId);
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
const actionValue = action.action.value;
|
|
612
|
-
if (!actionValue?.cmd)
|
|
613
|
-
return;
|
|
614
|
-
logger.info(`card action: ${actionValue.cmd}`);
|
|
615
|
-
const userId = action.operator.openId;
|
|
616
|
-
const chatId = action.chatId;
|
|
617
|
-
const messageId = action.messageId;
|
|
618
|
-
// Add Typing reaction to indicate "still alive"
|
|
619
|
-
void connector.addReaction(messageId, 'Typing');
|
|
620
|
-
if (actionValue.cmd === 'stop') {
|
|
621
|
-
// 委托 router 的 handleCardStop(单一来源):miss 反馈与 DSH unconfirmed
|
|
622
|
-
// 警告(「未能确认 turn 已停止,可能仍在后台运行」)都在那里。此前这里
|
|
623
|
-
// 内联只调 interruptCurrentRun——unconfirmed 反馈被丢,run 卡被翻成
|
|
624
|
-
// interrupted 却暗示干净停止;miss 文案也与 router 形成两份漂移拷贝。
|
|
625
|
-
// stop 绕串行队列的原则不变:本分支在队列分发之前直接 return。
|
|
626
|
-
void router
|
|
627
|
-
.handleCardAction({ ...actionValue, cmd: 'stop' }, { userId, chatId, messageId })
|
|
628
|
-
.catch((err) => logger.error('[control] card stop failed:', err));
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
const isImmediate = isImmediateAction(actionValue.cmd);
|
|
632
|
-
const cwd = sessionStore.getCwd(userId) ?? '';
|
|
633
|
-
// Build full value object — spread all fields from actionValue, then merge
|
|
634
|
-
// component out-of-band fields (option/formValue/inputValue/options) where
|
|
635
|
-
// present. See card-action-payload.ts for the component-type contract.
|
|
636
|
-
const fullValue = buildCardActionFullValue(actionValue, action);
|
|
637
|
-
// 定序(2026-10-07):会话 / cwd / agent 变更类卡片动作(new-session、
|
|
638
|
-
// resume.use、ls.switch、ws.use、config.save)执行前先冲刷入站装配窗口,
|
|
639
|
-
// 保证窗口内先到的文本消息在本动作之前 commit(绑定旧 session/cwd/agent)。
|
|
640
|
-
// 否则 700ms 静默窗内的消息会漂移到动作之后的新状态:例如输入「重新构建」
|
|
641
|
-
// 后立即点「新会话」,消息会落到新会话。名单以 router 单源为准。
|
|
642
|
-
//
|
|
643
|
-
// 有界等待:flush 会等 in-flight 媒体下载落定(超时 5 分钟/
|
|
644
|
-
// 资源),而 SDK 的 chatQueue.cardActions 默认 'same'——卡片动作与消息共享
|
|
645
|
-
// 同一 per-chat 串行 lane 且被 await 到底。无界等待下,一个大视频在途 +
|
|
646
|
-
// 点「新会话」= 整个 chat 的所有消息与卡片动作停摆到下载超时。超预算后
|
|
647
|
-
// 放行本动作,flush 在后台继续:窗口内仍有未落定下载时,文本 commit 可能
|
|
648
|
-
// 落到本动作之后(漂移风险收窄到「卡住的下载 + 2s 内点击」的组合,可接受;
|
|
649
|
-
// 完整解法需要把 binding 快照进装配器,见 inbound-unified-input-design.md)。
|
|
650
|
-
if (SESSION_MUTATING_ACTION_CMDS.has(actionValue.cmd)) {
|
|
651
|
-
const flushP = assembler
|
|
652
|
-
.flush(userId, chatId, 'flush')
|
|
653
|
-
.catch((err) => logger.error('[control] inbound flush failed:', err));
|
|
654
|
-
await Promise.race([flushP, sleep(SESSION_MUTATING_FLUSH_BUDGET_MS)]);
|
|
655
|
-
}
|
|
656
|
-
// queue.input / config.save / approval.respond / approval.toggle /
|
|
657
|
-
// approval.answer 系列返回 CardActionResponse toast 给点击用户即时反馈。
|
|
658
|
-
// 必须直接返回值 -- enqueueImmediate / enqueueConfigAction 是
|
|
659
|
-
// fire-and-forget,会吞掉返回值(answer 家族曾漏在直返列表外,
|
|
660
|
-
// 过期/重复 nonce/非法选项等错误 toast 被静默吞掉)。
|
|
661
|
-
// 审批响应尤其不能落串行队列:run 任务占用队列头直到 turn 结束,审批响应
|
|
662
|
-
// 排在后面会形成死锁(run 不结束不执行、run 结束 coordinator 已删响应空转)。
|
|
663
|
-
// order.aliasInput / order.aliasRemove / order.textInput 也返回 toast+card,
|
|
664
|
-
// 需直返避免被吞(order.textInput 曾漏在列表外,编辑卡停留在编辑界面)。
|
|
665
|
-
// approval.planFeedback 同 answer 系列:附意见的 toast 需直返 + 不落串行队列。
|
|
666
|
-
// 直返名单以 router 的 DIRECT_RETURN_CMDS 为单一来源(历史注释:
|
|
667
|
-
// order.textInput 曾漏在列表外、answer 家族曾漏在直返列表外——两份拷贝漂移)。
|
|
668
|
-
if (DIRECT_RETURN_CMDS.has(actionValue.cmd)) {
|
|
669
|
-
return router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
670
|
-
}
|
|
671
|
-
// order.exec → equivalent queued message (Plan A): resolve the order text
|
|
672
|
-
// at the enqueue boundary and route it through router.handle, exactly like
|
|
673
|
-
// a hand-typed message. See order-exec-dispatch.ts for the contract and
|
|
674
|
-
// why an internal key replaces the Feishu card messageId.
|
|
675
|
-
// (order.aliasEdit 是控制动作,走 enqueueImmediate 弹卡即可,不需直返。)
|
|
676
|
-
if (actionValue.cmd === 'order.exec') {
|
|
677
|
-
void dispatchOrderExecForQueue({
|
|
678
|
-
router,
|
|
679
|
-
bridge,
|
|
680
|
-
cwd,
|
|
681
|
-
orderId: actionValue.orderId,
|
|
682
|
-
ctx: { userId, chatId, messageId },
|
|
683
|
-
}).catch((err) => logger.error('[control] order.exec dispatch failed:', err));
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
// 非直返命令到这里已经没有回调响应可回(enqueue* 是 fire-and-forget),
|
|
687
|
-
// handler 返回的 toast 必须显式转成持久文本,否则失败毫无反馈。
|
|
688
|
-
const forwardActionFeedback = (res) => {
|
|
689
|
-
const text = actionFeedbackText(res);
|
|
690
|
-
if (!text)
|
|
691
|
-
return;
|
|
692
|
-
void bridge
|
|
693
|
-
.sendResult({ text }, { userId, chatId, messageId })
|
|
694
|
-
.catch((err) => logger.error('[control] card action feedback failed:', err));
|
|
695
|
-
};
|
|
696
|
-
if (isImmediate) {
|
|
697
|
-
bridge.enqueueImmediate(cwd, async () => {
|
|
698
|
-
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
699
|
-
forwardActionFeedback(res);
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
else {
|
|
703
|
-
bridge.enqueue(cwd, async () => {
|
|
704
|
-
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
705
|
-
forwardActionFeedback(res);
|
|
706
|
-
}, {
|
|
707
|
-
taskMeta: {
|
|
708
|
-
userId,
|
|
709
|
-
chatId,
|
|
710
|
-
// 队列身份 key 必须与「这一次点击」1:1:同一张卡片可点 N 次
|
|
711
|
-
// (Compact 尤其明显),复用卡片 messageId 会让同 key 的多条排队
|
|
712
|
-
// 任务在 queuedTasks/taskIndex/queueCardMessages 三处 lookup 里互相
|
|
713
|
-
// 顶替(2026-10-08 线上:连点 Compact 三次 → 三张卡都显示「第 1 位」、
|
|
714
|
-
// 两张永久停在「等待中」,只压缩一次)。
|
|
715
|
-
messageId: router.mintQueueTaskKey('card', actionValue.cmd),
|
|
716
|
-
// 卡片 messageId 仍然要留着当回复目标:排队卡要回复到被点击的卡片,
|
|
717
|
-
// 内部 key 不是合法飞书 id(replyTo 用它会 400)。
|
|
718
|
-
feishuReplyTo: messageId,
|
|
719
|
-
messagePreview: `card action: ${actionValue.cmd}`,
|
|
720
|
-
// lane 占用者的展示类型:压缩类动作在排队卡上自报身份
|
|
721
|
-
// (「正在执行: 🗜 Compact」),并据此禁用「立即执行」。
|
|
722
|
-
kind: COMPACT_ACTION_CMDS.has(actionValue.cmd) ? 'compact' : 'command',
|
|
723
|
-
// Compact 是单向操作,排队卡不允许编辑(编辑预览无意义)。
|
|
724
|
-
editable: !COMPACT_ACTION_CMDS.has(actionValue.cmd),
|
|
725
|
-
},
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
326
|
async function main() {
|
|
731
327
|
const cliArgs = parseCliArgs();
|
|
732
328
|
// --help/--version must bypass config loading and singleton lock — it's a pure
|
|
@@ -826,7 +422,17 @@ async function main() {
|
|
|
826
422
|
updateCachePath: path.join(configDir, 'update-cache.json'),
|
|
827
423
|
cloneSession,
|
|
828
424
|
});
|
|
829
|
-
setupMessageHandlers(
|
|
425
|
+
setupMessageHandlers({
|
|
426
|
+
connector,
|
|
427
|
+
router,
|
|
428
|
+
bridge,
|
|
429
|
+
sessionStore,
|
|
430
|
+
workspaceStore,
|
|
431
|
+
binder,
|
|
432
|
+
logger,
|
|
433
|
+
config,
|
|
434
|
+
cloneSession,
|
|
435
|
+
});
|
|
830
436
|
try {
|
|
831
437
|
await connector.connect();
|
|
832
438
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AppConfig } from './config/index.js';
|
|
2
|
+
import { getLogger } from './logger/index.js';
|
|
3
|
+
import type { Bridge } from './bridge/index.js';
|
|
4
|
+
import type { FeishuConnector } from './connector/index.js';
|
|
5
|
+
import type { CommandRouter } from './router/index.js';
|
|
6
|
+
import type { SessionStore } from './session/index.js';
|
|
7
|
+
import type { WorkspaceStore } from './workspace/index.js';
|
|
8
|
+
import type { OwnerBinder } from './binder.js';
|
|
9
|
+
import type { CloneSession } from './clone.js';
|
|
10
|
+
/**
|
|
11
|
+
* /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
|
|
12
|
+
* 共用同一判定,防止将来加别名时两处漂移。
|
|
13
|
+
*/
|
|
14
|
+
export declare function isStopCommand(text: string): boolean;
|
|
15
|
+
export interface MessageHandlerDeps {
|
|
16
|
+
connector: FeishuConnector;
|
|
17
|
+
router: CommandRouter;
|
|
18
|
+
bridge: Bridge;
|
|
19
|
+
sessionStore: SessionStore;
|
|
20
|
+
workspaceStore: WorkspaceStore;
|
|
21
|
+
binder: OwnerBinder;
|
|
22
|
+
logger: ReturnType<typeof getLogger>;
|
|
23
|
+
config: AppConfig;
|
|
24
|
+
cloneSession: CloneSession;
|
|
25
|
+
}
|
|
26
|
+
export declare function setupMessageHandlers(deps: MessageHandlerDeps): void;
|