lark-remote 0.5.10 → 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 +51 -2
- package/README.en.md +1 -14
- package/README.md +1 -14
- package/dist/binder.js +7 -1
- 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 +89 -230
- 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/approval-render.d.ts +14 -0
- package/dist/card/approval-render.js +155 -29
- package/dist/card/bash-card-session.js +10 -5
- package/dist/card/bash-renderer.js +11 -23
- package/dist/card/card-budget.js +24 -4
- 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 +150 -10
- package/dist/card/run-state.d.ts +12 -1
- package/dist/card/run-state.js +73 -34
- package/dist/common/text-width.js +5 -0
- package/dist/common/tool-summary.js +4 -2
- package/dist/config/codex-config.js +5 -3
- package/dist/config/index.d.ts +4 -2
- package/dist/config/index.js +19 -5
- package/dist/inbound/turn-assembler.js +6 -0
- package/dist/index.js +15 -391
- package/dist/instance-lock.d.ts +9 -1
- package/dist/instance-lock.js +42 -7
- package/dist/message-handlers.d.ts +26 -0
- package/dist/message-handlers.js +275 -0
- package/dist/platform/identity.js +4 -3
- package/dist/platform/path.js +3 -1
- package/dist/platform/terminator-win32.d.ts +13 -1
- package/dist/platform/terminator-win32.js +4 -14
- 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 +93 -1133
- 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/runner.js +12 -0
- 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 +15 -8
- package/dist/runner/common/acp/base-translator.js +38 -7
- package/dist/runner/common/jsonl-stream.js +12 -1
- package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
- package/dist/runner/common/jsonrpc/transport.js +7 -1
- 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/approval.js +16 -3
- package/dist/runner/dsh/auth.js +2 -3
- package/dist/runner/dsh/client.d.ts +32 -7
- package/dist/runner/dsh/client.js +115 -149
- 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 +63 -24
- 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/runner.d.ts +7 -0
- package/dist/runner/dsh/runner.js +42 -5
- 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/runner.js +42 -5
- package/dist/runner/kimi/acp/translator.d.ts +8 -0
- package/dist/runner/kimi/acp/translator.js +11 -12
- package/dist/runner/opencode/acp/translator.js +1 -12
- package/dist/runner/pi/rpc/runner.js +33 -5
- 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/resolve-agent-choices.js +5 -1
- package/dist/runner/sync-agent-choices.js +4 -1
- package/dist/runner/types.d.ts +17 -1
- package/dist/session/claude/session-index.js +13 -4
- package/dist/session/codex/rollout-reader.js +51 -26
- package/dist/session/common/usage-accumulator.d.ts +10 -1
- package/dist/session/common/usage-accumulator.js +10 -2
- package/dist/session/dsh/sessions.js +29 -9
- package/dist/session/kimi/sessions.js +11 -7
- package/dist/session/opencode/sessions.js +15 -7
- package/dist/session/pi/sessions.js +3 -2
- package/dist/session/session-store.js +4 -3
- 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,22 +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
30
|
import path from 'node:path';
|
|
38
|
-
import
|
|
39
|
-
import { bestEffortUnlink } from './common/fs.js';
|
|
40
|
-
/**
|
|
41
|
-
* /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
|
|
42
|
-
* 共用同一判定,防止将来加别名时两处漂移。
|
|
43
|
-
*/
|
|
44
|
-
function isStopCommand(text) {
|
|
45
|
-
const t = text.trim().toLowerCase();
|
|
46
|
-
return t === '/stop' || t === '/t';
|
|
47
|
-
}
|
|
31
|
+
import { setupMessageHandlers } from './message-handlers.js';
|
|
48
32
|
/** Send auto-resume card for a restored session */
|
|
49
33
|
async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false, stillRunning = false) {
|
|
50
34
|
if (!contact.userId)
|
|
@@ -317,12 +301,6 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
317
301
|
getLogger().warn(`[dsh] startup probe failed: ${error.message}`);
|
|
318
302
|
});
|
|
319
303
|
}
|
|
320
|
-
// 显示名单源在 runner/types.ts 的 AGENT_DISPLAY_NAMES(启动注册与回退共用)
|
|
321
|
-
for (const kind of AGENT_KINDS) {
|
|
322
|
-
agentRegistry.registerDisplayName(kind, AGENT_DISPLAY_NAMES[kind]);
|
|
323
|
-
}
|
|
324
|
-
// 设置全局 registry,供 agentDisplayName 等全局函数使用
|
|
325
|
-
AgentRegistry.setGlobalInstance(agentRegistry);
|
|
326
304
|
// Probe agent CLI availability (fire-and-forget, populates cache for /config card).
|
|
327
305
|
// Don't block startup — log unavailable agents as warnings.
|
|
328
306
|
probeAllAgents()
|
|
@@ -335,8 +313,8 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
335
313
|
getLogger().info('[probe] all agent CLIs available');
|
|
336
314
|
}
|
|
337
315
|
})
|
|
338
|
-
.catch(() => {
|
|
339
|
-
|
|
316
|
+
.catch((err) => {
|
|
317
|
+
getLogger().warn(`[probe] background probe failed: ${err.message}`);
|
|
340
318
|
});
|
|
341
319
|
// Warm the codex model catalog in the background so the first Codex /config
|
|
342
320
|
// card open reads a warm cache instead of blocking the event loop for up to
|
|
@@ -345,370 +323,6 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
345
323
|
warmCodexCatalogCache();
|
|
346
324
|
return { agentRegistry, sessionReaderRegistry };
|
|
347
325
|
}
|
|
348
|
-
/** Setup message and card action handlers. */
|
|
349
|
-
function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
|
|
350
|
-
// 入站统一装配器(2026-09-15):图/文任意顺序 → 同一个 turn、同一个 prompt,
|
|
351
|
-
// 附件路径统一注入 prompt;无文本纯附件只回执(决策 2)。
|
|
352
|
-
const assembler = new InboundTurnAssembler({
|
|
353
|
-
onCommit: (turn, prompt) => {
|
|
354
|
-
// 入队时刻快照 cwd + agent/session 绑定,语义与旧路径一致
|
|
355
|
-
// (排队期间 /cd、/config 不再导致语义漂移)。
|
|
356
|
-
const messageId = turn.messageIds[turn.messageIds.length - 1] ?? '';
|
|
357
|
-
// turnMessageIds:终态收尾要覆盖本轮每一条消息(逐条挂的 Typing 得逐条撤)
|
|
358
|
-
const ctx = {
|
|
359
|
-
userId: turn.userId,
|
|
360
|
-
chatId: turn.chatId,
|
|
361
|
-
messageId,
|
|
362
|
-
turnMessageIds: turn.messageIds,
|
|
363
|
-
};
|
|
364
|
-
let cwd = sessionStore.getCwd(turn.userId) ?? '';
|
|
365
|
-
if (!cwd && workspaceStore) {
|
|
366
|
-
const workspaces = workspaceStore.list();
|
|
367
|
-
if (workspaces.length > 0)
|
|
368
|
-
cwd = workspaces[0].path;
|
|
369
|
-
}
|
|
370
|
-
const binding = bridge.currentBinding(turn.userId);
|
|
371
|
-
bridge.enqueue(cwd, async () => {
|
|
372
|
-
// allowCommandPrefix: false —— prompt 已剥离占位符,绝不再做命令分发。
|
|
373
|
-
await router.handle(prompt, ctx, {
|
|
374
|
-
cwdOverride: cwd,
|
|
375
|
-
binding,
|
|
376
|
-
allowCommandPrefix: false,
|
|
377
|
-
});
|
|
378
|
-
}, {
|
|
379
|
-
taskMeta: {
|
|
380
|
-
userId: turn.userId,
|
|
381
|
-
chatId: turn.chatId,
|
|
382
|
-
messageId,
|
|
383
|
-
messagePreview: prompt.slice(0, 3000),
|
|
384
|
-
binding,
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
},
|
|
388
|
-
onReceipt: async (ctx, text) => {
|
|
389
|
-
await bridge.sendResult({ text }, ctx);
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
router.setInboundFlusher(() => assembler.flushAll('flush'));
|
|
393
|
-
// 统一的入站授权入口(文本 + 媒体共用):未绑定时,首条入站消息——无论
|
|
394
|
-
// 纯文本、纯媒体还是图文混合——都完成 owner 绑定并触发一次 onboarding。
|
|
395
|
-
// 媒体入口原先只做只读 isOwner 检查,新安装用户第一条消息是截图时既不绑定、
|
|
396
|
-
// 也不保存、也没有任何反馈。
|
|
397
|
-
const authorizer = new InboundAuthorizer({
|
|
398
|
-
binder,
|
|
399
|
-
onBind: (userId, chatId) => {
|
|
400
|
-
// First-run onboarding: set default cwd + send welcome + Help card.
|
|
401
|
-
// setCwd is synchronous (before returning the async send promise) so the
|
|
402
|
-
// user's next message — which will hit the `owner` branch now that
|
|
403
|
-
// startup-contact.json has been written — finds cwd already set.
|
|
404
|
-
// process.cwd() is the only sensible default: the user started
|
|
405
|
-
// lark-remote in the directory they want to work in.
|
|
406
|
-
// setCwd only when no cwd is set yet (e.g. fresh start after bind).
|
|
407
|
-
// Don't overwrite an existing cwd — the user may have already set one
|
|
408
|
-
// via /cd or a previous bind. Use realpath for canonical form (matching
|
|
409
|
-
// Claude JSONL cwd, e.g. /tmp → /private/tmp on macOS).
|
|
410
|
-
let resolvedCwd;
|
|
411
|
-
try {
|
|
412
|
-
resolvedCwd = fs.realpathSync(process.cwd());
|
|
413
|
-
}
|
|
414
|
-
catch {
|
|
415
|
-
resolvedCwd = process.cwd();
|
|
416
|
-
}
|
|
417
|
-
if (!sessionStore.getCwd(userId)) {
|
|
418
|
-
sessionStore.setCwd(userId, resolvedCwd);
|
|
419
|
-
}
|
|
420
|
-
return (async () => {
|
|
421
|
-
try {
|
|
422
|
-
// Message 1: bind confirmation + status (combined to avoid
|
|
423
|
-
// Feishu out-of-order delivery across three separate messages).
|
|
424
|
-
await connector.sendWithRetry(chatId, {
|
|
425
|
-
text: `✅ 已绑定到本账号,此后仅你可使用本应用\n` +
|
|
426
|
-
`📂 当前工作目录: \`${resolvedCwd}\`\n` +
|
|
427
|
-
`🤖 默认 Agent: ${agentDisplayName(config.defaultAgent)}\n` +
|
|
428
|
-
`💡 直接输入消息即可开始对话,或 /cd 切换目录`,
|
|
429
|
-
});
|
|
430
|
-
// Message 2: Help card.
|
|
431
|
-
// Use router.cmdHelp() + manual send (without replyTo) so the Help
|
|
432
|
-
// card appears as an independent message, not a reply to the PIN
|
|
433
|
-
// message. router.handle('/help') would set replyTo=msg.messageId
|
|
434
|
-
// (the PIN), causing the card to nest under the PIN in Feishu UI.
|
|
435
|
-
const helpResult = router.cmdHelp();
|
|
436
|
-
if (helpResult.card) {
|
|
437
|
-
await connector.sendWithRetry(chatId, { card: helpResult.card });
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
catch (err) {
|
|
441
|
-
logger.error('[binder] onboarding send failed:', err);
|
|
442
|
-
}
|
|
443
|
-
})();
|
|
444
|
-
},
|
|
445
|
-
});
|
|
446
|
-
// 入站媒体(图片/文件/视频/语音/表情):先认证后下载(修复)。
|
|
447
|
-
// connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
|
|
448
|
-
// 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
|
|
449
|
-
// 下载是异步的:立刻把在途 promise 交给装配器(`kind: 'media'`),
|
|
450
|
-
// 保证「先图后文」不会先提交一个只含文本的 turn。
|
|
451
|
-
connector.setInboundMediaDetectedHandler((msg) => {
|
|
452
|
-
// 与文本入口共用授权:首条纯媒体也在这里完成 owner 绑定(bind_success
|
|
453
|
-
// 继续走下载 + 装配,纯媒体最终由装配器发 no-text 回执)。
|
|
454
|
-
const authorization = authorizer.authorize(msg.userId, msg.chatId);
|
|
455
|
-
if (authorization === 'rejected') {
|
|
456
|
-
logger.warn(`[media] rejected inbound media from non-owner ${msg.userId}`);
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
// 读 router.config(活引用):/config 保存后 setConfigValues 返回新对象,
|
|
460
|
-
// 启动时捕获的 config 参数会过期(修复)。
|
|
461
|
-
if (!router.config.inboundMedia.enabled) {
|
|
462
|
-
// 不静默:关闭时给 owner 明确反馈,避免发图后无任何反应。
|
|
463
|
-
// 不下载、不落盘,只进 rejected(随 turn 回执一起发出)。
|
|
464
|
-
assembler.ingest({
|
|
465
|
-
kind: 'rejected',
|
|
466
|
-
userId: msg.userId,
|
|
467
|
-
chatId: msg.chatId,
|
|
468
|
-
messageId: msg.messageId,
|
|
469
|
-
replyToMessageId: msg.replyToMessageId,
|
|
470
|
-
rawContentType: msg.rawContentType,
|
|
471
|
-
rejectedKind: 'file',
|
|
472
|
-
reason: '入站媒体保存已关闭(inboundMedia.enabled: false),未保存文件。' +
|
|
473
|
-
'如需自动保存图片/文件,请开启后重试',
|
|
474
|
-
});
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
const outcome = (async () => {
|
|
478
|
-
const payload = await connector.downloadInboundMedia(msg, {
|
|
479
|
-
maxFileSizeMb: router.config.inboundMedia.maxFileSizeMb,
|
|
480
|
-
});
|
|
481
|
-
try {
|
|
482
|
-
return await bridge.saveInboundMedia(payload);
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
// save() 的正常路径会清理临时文件;这里兜底 save 进入 mkdir
|
|
486
|
-
// try 之前意外抛错的情况,避免 os.tmpdir 残留。
|
|
487
|
-
for (const item of payload.media) {
|
|
488
|
-
bestEffortUnlink(item.tempPath);
|
|
489
|
-
}
|
|
490
|
-
throw err;
|
|
491
|
-
}
|
|
492
|
-
})();
|
|
493
|
-
assembler.ingest({
|
|
494
|
-
kind: 'media',
|
|
495
|
-
userId: msg.userId,
|
|
496
|
-
chatId: msg.chatId,
|
|
497
|
-
messageId: msg.messageId,
|
|
498
|
-
replyToMessageId: msg.replyToMessageId,
|
|
499
|
-
rawContentType: msg.rawContentType,
|
|
500
|
-
outcome,
|
|
501
|
-
});
|
|
502
|
-
});
|
|
503
|
-
connector.setMessageHandler((msg) => {
|
|
504
|
-
// 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息认领。
|
|
505
|
-
// 与媒体入口共用 InboundAuthorizer:bind_success 的 onboarding 只跑一次
|
|
506
|
-
// (同一条 post 同时带图和文字时两个入口都会看到 bind_success)。
|
|
507
|
-
const decision = authorizer.authorize(msg.userId, msg.chatId);
|
|
508
|
-
if (decision === 'rejected')
|
|
509
|
-
return;
|
|
510
|
-
// 首条消息只用于完成绑定,不转发给 agent(与旧行为一致)。
|
|
511
|
-
if (decision === 'bind_success')
|
|
512
|
-
return;
|
|
513
|
-
// decision === 'owner':正常处理(不再每条覆盖 startup-contact)
|
|
514
|
-
logger.info(`message from ${msg.userId} (${msg.rawContentType}): ${msg.content.slice(0, 100)}`);
|
|
515
|
-
// Add Typing reaction to indicate "still alive"
|
|
516
|
-
void connector.addReaction(msg.messageId, 'Typing');
|
|
517
|
-
// 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
|
|
518
|
-
// 绕队列停止能力),不转发 coding agent、不进命令分发、不展开别名。
|
|
519
|
-
if (cloneSession.isActive() && !isStopCommand(msg.content)) {
|
|
520
|
-
void cloneSession
|
|
521
|
-
.handleMessage(msg.content, {
|
|
522
|
-
userId: msg.userId,
|
|
523
|
-
chatId: msg.chatId,
|
|
524
|
-
messageId: msg.messageId,
|
|
525
|
-
})
|
|
526
|
-
.catch((err) => logger.error('[clone] message handling failed:', err));
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
// 别名展开:命令分发前对消息做一次 $name 展开(不递归)。
|
|
530
|
-
// `!` / `/` 开头的消息不会进入展开路径($PATH、$HOME 等 shell 变量不受影响),
|
|
531
|
-
// 未知 $xxx 原样透传;展开结果若以 `/` 开头会自然落入命令路径。
|
|
532
|
-
const content = router.expandAliasMessage(msg.content);
|
|
533
|
-
if (isStopCommand(content)) {
|
|
534
|
-
void (async () => {
|
|
535
|
-
const interrupt = await bridge.interruptCurrentRunDetailed({
|
|
536
|
-
userId: msg.userId,
|
|
537
|
-
chatId: msg.chatId,
|
|
538
|
-
});
|
|
539
|
-
const ctx = { userId: msg.userId, chatId: msg.chatId, messageId: msg.messageId };
|
|
540
|
-
if (!interrupt.stopped) {
|
|
541
|
-
await bridge.sendResult({ text: '当前没有运行中的进程' }, ctx);
|
|
542
|
-
}
|
|
543
|
-
else if ((interrupt.stoppedCount ?? 1) > 1) {
|
|
544
|
-
// 并行 workspace 时 /stop 停全部,必须让用户看到数量。
|
|
545
|
-
await bridge.sendResult({ text: `✅ 已停止 ${interrupt.stoppedCount} 个运行中的任务` }, ctx);
|
|
546
|
-
}
|
|
547
|
-
})().catch((err) => logger.error('[control] /stop failed:', err));
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
// 先清洗,后判语义(§5.6):结构占位符永远不能触发命令分发。
|
|
551
|
-
// `` 的首字符恰好是 `!` —— 2026-09-15 事故就是这么进 shell 的。
|
|
552
|
-
const stripped = stripPlaceholders(content);
|
|
553
|
-
if (stripped.unknownTags.length > 0) {
|
|
554
|
-
logger.warn(`[inbound] unknown placeholder tags stripped: ${stripped.unknownTags.join(', ')} ` +
|
|
555
|
-
`(msg_type=${msg.rawContentType})`);
|
|
556
|
-
}
|
|
557
|
-
const commandEligible = msg.rawContentType === 'text' && stripped.kinds.length === 0 && stripped.clean !== '';
|
|
558
|
-
// 命令消息旁路装配窗口:/命令 立即执行,但必须先等窗口内已装配的内容
|
|
559
|
-
// (含在途媒体下载)提交完成。此前 `void flush()` 不等待,
|
|
560
|
-
// 有下载在途时命令会先于 commit 执行——发送在 /cd、/new 前的消息被绑定到
|
|
561
|
-
// 新 cwd/session,媒体也落到新目录。/stop 已在上面走快速路径,不受影响。
|
|
562
|
-
if (commandEligible && (stripped.clean.startsWith('/') || stripped.clean.startsWith('!'))) {
|
|
563
|
-
void (async () => {
|
|
564
|
-
await assembler.flush(msg.userId, msg.chatId, 'flush');
|
|
565
|
-
await router.handle(stripped.clean, {
|
|
566
|
-
userId: msg.userId,
|
|
567
|
-
chatId: msg.chatId,
|
|
568
|
-
messageId: msg.messageId,
|
|
569
|
-
}, { allowCommandPrefix: true });
|
|
570
|
-
})().catch((err) => logger.error('[control] command failed:', err));
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
// 普通消息:进装配器,等静默期窗口到期(下载落定)后合并成一个 turn。
|
|
574
|
-
assembler.ingest({
|
|
575
|
-
kind: 'text',
|
|
576
|
-
userId: msg.userId,
|
|
577
|
-
chatId: msg.chatId,
|
|
578
|
-
messageId: msg.messageId,
|
|
579
|
-
replyToMessageId: msg.replyToMessageId,
|
|
580
|
-
rawContentType: msg.rawContentType,
|
|
581
|
-
text: stripped.clean,
|
|
582
|
-
placeholders: stripped.kinds,
|
|
583
|
-
unknownTags: stripped.unknownTags,
|
|
584
|
-
});
|
|
585
|
-
});
|
|
586
|
-
connector.setCardActionHandler(async (action) => {
|
|
587
|
-
// 仅已绑定的 owner 可触发卡片操作;其余静默丢弃(计数 + debug)
|
|
588
|
-
if (!binder.isOwner(action.operator.openId)) {
|
|
589
|
-
binder.recordRejectedCardAction(action.operator.openId);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
const actionValue = action.action.value;
|
|
593
|
-
if (!actionValue?.cmd)
|
|
594
|
-
return;
|
|
595
|
-
logger.info(`card action: ${actionValue.cmd}`);
|
|
596
|
-
const userId = action.operator.openId;
|
|
597
|
-
const chatId = action.chatId;
|
|
598
|
-
const messageId = action.messageId;
|
|
599
|
-
// Add Typing reaction to indicate "still alive"
|
|
600
|
-
void connector.addReaction(messageId, 'Typing');
|
|
601
|
-
if (actionValue.cmd === 'stop') {
|
|
602
|
-
if (!actionValue.runId) {
|
|
603
|
-
logger.warn('[control] ignored stop card action without runId');
|
|
604
|
-
// Card button clicks must have visible feedback (design constraint: card button
|
|
605
|
-
// 点击必须有可见反馈,见 design.md §6.2)。
|
|
606
|
-
void bridge
|
|
607
|
-
.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, { userId, chatId, messageId })
|
|
608
|
-
.catch((err) => logger.error('[control] card stop feedback failed:', err));
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
void (async () => {
|
|
612
|
-
const stopped = await bridge.interruptCurrentRun({
|
|
613
|
-
userId,
|
|
614
|
-
chatId,
|
|
615
|
-
runId: actionValue.runId,
|
|
616
|
-
});
|
|
617
|
-
if (!stopped) {
|
|
618
|
-
// runId mismatch or run already exited — mirror /stop text miss path.
|
|
619
|
-
await bridge.sendResult({ text: '该任务已结束,无需终止' }, { userId, chatId, messageId });
|
|
620
|
-
}
|
|
621
|
-
})().catch((err) => logger.error('[control] card stop failed:', err));
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
const isImmediate = isImmediateAction(actionValue.cmd);
|
|
625
|
-
const cwd = sessionStore.getCwd(userId) ?? '';
|
|
626
|
-
// Build full value object — spread all fields from actionValue, then merge
|
|
627
|
-
// component out-of-band fields (option/formValue/inputValue/options) where
|
|
628
|
-
// present. See card-action-payload.ts for the component-type contract.
|
|
629
|
-
const fullValue = buildCardActionFullValue(actionValue, action);
|
|
630
|
-
// 定序(2026-10-07):会话 / cwd / agent 变更类卡片动作(new-session、
|
|
631
|
-
// resume.use、ls.switch、ws.use、config.save)执行前先冲刷入站装配窗口,
|
|
632
|
-
// 保证窗口内先到的文本消息在本动作之前 commit(绑定旧 session/cwd/agent)。
|
|
633
|
-
// 否则 700ms 静默窗内的消息会漂移到动作之后的新状态:例如输入「重新构建」
|
|
634
|
-
// 后立即点「新会话」,消息会落到新会话。名单以 router 单源为准。
|
|
635
|
-
if (SESSION_MUTATING_ACTION_CMDS.has(actionValue.cmd)) {
|
|
636
|
-
await assembler.flush(userId, chatId, 'flush');
|
|
637
|
-
}
|
|
638
|
-
// queue.input / config.save / approval.respond / approval.toggle /
|
|
639
|
-
// approval.answer 系列返回 CardActionResponse toast 给点击用户即时反馈。
|
|
640
|
-
// 必须直接返回值 -- enqueueImmediate / enqueueConfigAction 是
|
|
641
|
-
// fire-and-forget,会吞掉返回值(answer 家族曾漏在直返列表外,
|
|
642
|
-
// 过期/重复 nonce/非法选项等错误 toast 被静默吞掉)。
|
|
643
|
-
// 审批响应尤其不能落串行队列:run 任务占用队列头直到 turn 结束,审批响应
|
|
644
|
-
// 排在后面会形成死锁(run 不结束不执行、run 结束 coordinator 已删响应空转)。
|
|
645
|
-
// order.aliasInput / order.aliasRemove / order.textInput 也返回 toast+card,
|
|
646
|
-
// 需直返避免被吞(order.textInput 曾漏在列表外,编辑卡停留在编辑界面)。
|
|
647
|
-
// approval.planFeedback 同 answer 系列:附意见的 toast 需直返 + 不落串行队列。
|
|
648
|
-
// 直返名单以 router 的 DIRECT_RETURN_CMDS 为单一来源(历史注释:
|
|
649
|
-
// order.textInput 曾漏在列表外、answer 家族曾漏在直返列表外——两份拷贝漂移)。
|
|
650
|
-
if (DIRECT_RETURN_CMDS.has(actionValue.cmd)) {
|
|
651
|
-
return router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
652
|
-
}
|
|
653
|
-
// order.exec → equivalent queued message (Plan A): resolve the order text
|
|
654
|
-
// at the enqueue boundary and route it through router.handle, exactly like
|
|
655
|
-
// a hand-typed message. See order-exec-dispatch.ts for the contract and
|
|
656
|
-
// why an internal key replaces the Feishu card messageId.
|
|
657
|
-
// (order.aliasEdit 是控制动作,走 enqueueImmediate 弹卡即可,不需直返。)
|
|
658
|
-
if (actionValue.cmd === 'order.exec') {
|
|
659
|
-
void dispatchOrderExecForQueue({
|
|
660
|
-
router,
|
|
661
|
-
bridge,
|
|
662
|
-
cwd,
|
|
663
|
-
orderId: actionValue.orderId,
|
|
664
|
-
ctx: { userId, chatId, messageId },
|
|
665
|
-
}).catch((err) => logger.error('[control] order.exec dispatch failed:', err));
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
// 非直返命令到这里已经没有回调响应可回(enqueue* 是 fire-and-forget),
|
|
669
|
-
// handler 返回的 toast 必须显式转成持久文本,否则失败毫无反馈。
|
|
670
|
-
const forwardActionFeedback = (res) => {
|
|
671
|
-
const text = actionFeedbackText(res);
|
|
672
|
-
if (!text)
|
|
673
|
-
return;
|
|
674
|
-
void bridge
|
|
675
|
-
.sendResult({ text }, { userId, chatId, messageId })
|
|
676
|
-
.catch((err) => logger.error('[control] card action feedback failed:', err));
|
|
677
|
-
};
|
|
678
|
-
if (isImmediate) {
|
|
679
|
-
bridge.enqueueImmediate(cwd, async () => {
|
|
680
|
-
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
681
|
-
forwardActionFeedback(res);
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
else {
|
|
685
|
-
bridge.enqueue(cwd, async () => {
|
|
686
|
-
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
687
|
-
forwardActionFeedback(res);
|
|
688
|
-
}, {
|
|
689
|
-
taskMeta: {
|
|
690
|
-
userId,
|
|
691
|
-
chatId,
|
|
692
|
-
// 队列身份 key 必须与「这一次点击」1:1:同一张卡片可点 N 次
|
|
693
|
-
// (Compact 尤其明显),复用卡片 messageId 会让同 key 的多条排队
|
|
694
|
-
// 任务在 queuedTasks/taskIndex/queueCardMessages 三处 lookup 里互相
|
|
695
|
-
// 顶替(2026-10-08 线上:连点 Compact 三次 → 三张卡都显示「第 1 位」、
|
|
696
|
-
// 两张永久停在「等待中」,只压缩一次)。
|
|
697
|
-
messageId: router.mintQueueTaskKey('card', actionValue.cmd),
|
|
698
|
-
// 卡片 messageId 仍然要留着当回复目标:排队卡要回复到被点击的卡片,
|
|
699
|
-
// 内部 key 不是合法飞书 id(replyTo 用它会 400)。
|
|
700
|
-
feishuReplyTo: messageId,
|
|
701
|
-
messagePreview: `card action: ${actionValue.cmd}`,
|
|
702
|
-
// lane 占用者的展示类型:压缩类动作在排队卡上自报身份
|
|
703
|
-
// (「正在执行: 🗜 Compact」),并据此禁用「立即执行」。
|
|
704
|
-
kind: COMPACT_ACTION_CMDS.has(actionValue.cmd) ? 'compact' : 'command',
|
|
705
|
-
// Compact 是单向操作,排队卡不允许编辑(编辑预览无意义)。
|
|
706
|
-
editable: !COMPACT_ACTION_CMDS.has(actionValue.cmd),
|
|
707
|
-
},
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
326
|
async function main() {
|
|
713
327
|
const cliArgs = parseCliArgs();
|
|
714
328
|
// --help/--version must bypass config loading and singleton lock — it's a pure
|
|
@@ -808,7 +422,17 @@ async function main() {
|
|
|
808
422
|
updateCachePath: path.join(configDir, 'update-cache.json'),
|
|
809
423
|
cloneSession,
|
|
810
424
|
});
|
|
811
|
-
setupMessageHandlers(
|
|
425
|
+
setupMessageHandlers({
|
|
426
|
+
connector,
|
|
427
|
+
router,
|
|
428
|
+
bridge,
|
|
429
|
+
sessionStore,
|
|
430
|
+
workspaceStore,
|
|
431
|
+
binder,
|
|
432
|
+
logger,
|
|
433
|
+
config,
|
|
434
|
+
cloneSession,
|
|
435
|
+
});
|
|
812
436
|
try {
|
|
813
437
|
await connector.connect();
|
|
814
438
|
}
|
package/dist/instance-lock.d.ts
CHANGED
|
@@ -33,7 +33,15 @@ export declare class InstanceLock {
|
|
|
33
33
|
* 与 CIM CommandLine 首 token 归一后必然一致(bun/node 垫片场景亦然)。
|
|
34
34
|
*/
|
|
35
35
|
private lockContents;
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。
|
|
38
|
+
*
|
|
39
|
+
* 先写完整临时文件再 `link()`:`open(wx)` 与 `write` 之间存在微秒级「空文件」
|
|
40
|
+
* 窗口,并发进程在该窗口读到的锁是「空 → 损坏 → 陈旧」,rename 夺走后双方
|
|
41
|
+
* 各持一份(A 的 fd 写进已被删掉的 inode、B 建了自己的锁 → 双实例)。
|
|
42
|
+
* link() 创建即含全部内容,窗口不存在;文件系统不支持 hardlink 时退回 wx
|
|
43
|
+
* 直建(该类 FS 上保留既有行为,竞态面收窄到该类文件系统)。
|
|
44
|
+
*/
|
|
37
45
|
private tryCreate;
|
|
38
46
|
/**
|
|
39
47
|
* 把夺来的活锁放回原位。优先 `link`(目标已存在会失败,不会覆盖更新的锁),
|
package/dist/instance-lock.js
CHANGED
|
@@ -177,22 +177,57 @@ export class InstanceLock {
|
|
|
177
177
|
lockContents(pid) {
|
|
178
178
|
return `${pid}\n${binaryName(process.execPath)}`;
|
|
179
179
|
}
|
|
180
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。
|
|
182
|
+
*
|
|
183
|
+
* 先写完整临时文件再 `link()`:`open(wx)` 与 `write` 之间存在微秒级「空文件」
|
|
184
|
+
* 窗口,并发进程在该窗口读到的锁是「空 → 损坏 → 陈旧」,rename 夺走后双方
|
|
185
|
+
* 各持一份(A 的 fd 写进已被删掉的 inode、B 建了自己的锁 → 双实例)。
|
|
186
|
+
* link() 创建即含全部内容,窗口不存在;文件系统不支持 hardlink 时退回 wx
|
|
187
|
+
* 直建(该类 FS 上保留既有行为,竞态面收窄到该类文件系统)。
|
|
188
|
+
*/
|
|
181
189
|
tryCreate() {
|
|
190
|
+
const tmp = `${this.lockPath}.new-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
182
191
|
try {
|
|
183
|
-
const fd = fs.openSync(
|
|
192
|
+
const fd = fs.openSync(tmp, 'wx');
|
|
184
193
|
try {
|
|
185
194
|
fs.writeSync(fd, this.lockContents(process.pid));
|
|
186
195
|
}
|
|
187
196
|
finally {
|
|
188
197
|
fs.closeSync(fd);
|
|
189
198
|
}
|
|
190
|
-
|
|
199
|
+
try {
|
|
200
|
+
fs.linkSync(tmp, this.lockPath);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const code = err.code;
|
|
205
|
+
if (code === 'EEXIST')
|
|
206
|
+
return false;
|
|
207
|
+
if (code === 'EPERM' || code === 'EOPNOTSUPP' || code === 'ENOSYS' || code === 'EXDEV') {
|
|
208
|
+
// hardlink 不可用(网络/overlay FS):退回 wx 直建。
|
|
209
|
+
try {
|
|
210
|
+
const direct = fs.openSync(this.lockPath, 'wx');
|
|
211
|
+
try {
|
|
212
|
+
fs.writeSync(direct, this.lockContents(process.pid));
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
fs.closeSync(direct);
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
catch (err2) {
|
|
220
|
+
if (err2.code === 'EEXIST')
|
|
221
|
+
return false;
|
|
222
|
+
throw err2;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
191
227
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
throw err;
|
|
228
|
+
finally {
|
|
229
|
+
// 成功 link 后 tmp 与 lockPath 同 inode:删 tmp 只去掉多余名字(nlink 2→1)。
|
|
230
|
+
bestEffortUnlink(tmp);
|
|
196
231
|
}
|
|
197
232
|
}
|
|
198
233
|
/**
|
|
@@ -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;
|