diff --git a/docs-site/docs/hooks.md b/docs-site/docs/hooks.md new file mode 100644 index 00000000..136159b7 --- /dev/null +++ b/docs-site/docs/hooks.md @@ -0,0 +1,95 @@ +# Lifecycle Hooks + +botmux 可以在关键生命周期事件发生时**异步调用外部命令**。命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。 + +## 配置位置 + +按优先级从高到低: + +1. `BOTMUX_HOOKS_JSON` 环境变量(直接传 JSON 数组) +2. `BOTMUX_HOOKS_FILE` 指定的文件路径 +3. 默认 `~/.botmux/data/hooks.json` + +## 快速验证:写入本地日志 + +仓库内置示例脚本,复制即用: + +```bash +chmod +x examples/hooks/echo-to-log.sh +HOOK_CMD="$(pwd)/examples/hooks/echo-to-log.sh" +mkdir -p ~/.botmux/data +cat > ~/.botmux/data/hooks.json < ~/.botmux/data/hooks.json <> "$log" +cat >> "$log" diff --git a/examples/hooks/http-webhook.sh b/examples/hooks/http-webhook.sh new file mode 100755 index 00000000..fb00af65 --- /dev/null +++ b/examples/hooks/http-webhook.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +endpoint="${1:?usage: http-webhook.sh }" + +curl -fsS \ + -X POST \ + -H 'Content-Type: application/json' \ + --data-binary @- \ + "$endpoint" >/dev/null diff --git a/examples/hooks/osascript-notify.sh b/examples/hooks/osascript-notify.sh new file mode 100755 index 00000000..52495730 --- /dev/null +++ b/examples/hooks/osascript-notify.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +payload="$(cat)" +title="botmux ${BOTMUX_HOOK_EVENT:-hook}" +body="$(printf '%s' "$payload" | tr '\n' ' ' | cut -c 1-240)" + +/usr/bin/osascript -e 'on run argv + display notification (item 2 of argv) with title (item 1 of argv) +end run' "$title" "$body" diff --git a/src/bot-registry.ts b/src/bot-registry.ts index ab250967..e7b68e88 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -153,6 +153,16 @@ export interface BotConfig { * (undefined) keeps the streaming card. For users who find the live card noisy. */ disableStreamingCard?: boolean; + /** + * Conversation mode for 1:1 private chats (DMs) with the bot: + * - 'thread' (default, stored as undefined): every top-level DM message + * starts a fresh thread-scoped session — the official/legacy behavior, + * keeps 1:1 chatter out of one long-running CLI process. + * - 'chat': route DMs as one flat, continuous chat-scoped session (all + * messages share the same context, similar to Hermes/OpenClaw). + * Editable at runtime via `/botconfig p2pMode chat|thread` (owner/admin). + */ + p2pMode?: 'thread' | 'chat'; /** chat_id list: chats where the live streaming card is suppressed (status falls back to master's pending-card morph). Written by `/card off|on`. */ noCardChats?: string[]; /** @@ -646,6 +656,9 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { // means "use default botmux brand". Don't trim-to-undefined here. brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined, disableStreamingCard: entry.disableStreamingCard === true || undefined, + // Only 'chat' is meaningful; 'thread' (and anything else) normalizes to + // undefined — the legacy thread-per-message default. Keeps bots.json clean. + p2pMode: entry.p2pMode === 'chat' ? 'chat' : undefined, noCardChats: Array.isArray(entry.noCardChats) ? entry.noCardChats.filter((x: any): x is string => typeof x === 'string' && x.trim().length > 0).map((x: string) => x.trim()) : undefined, diff --git a/src/cli.ts b/src/cli.ts index d9f531d9..a45df600 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -51,6 +51,7 @@ import { createCliAdapterSync } from './adapters/cli/registry.js'; import { logger } from './utils/logger.js'; import { invalidWorkingDirs } from './utils/working-dir.js'; import { firstPositional } from './cli/arg-utils.js'; +import { dispatchPrimaryMessage } from './cli/send-dispatch.js'; import { formatBotInfoEntriesForCli, formatChatBotsForCli, @@ -3028,13 +3029,20 @@ async function cmdSend(rest: string[]): Promise { // addressing to go to the last caller in the shared oncall workspace. const oncallEntry = !sendTopLevel && !overrideChatId && !sendInto && s.chatId ? findOncallChatForAnyBot(s.chatId) : undefined; + + const hookContext = { + sessionId: sid, + chatId: s.chatId, + rootMessageId: s.rootMessageId, + title: s.title, + }; // Dispatch helper: top-level / chat-scope send vs reply-in-thread, single // decision point. Used for file attachments (always plain in chat scope). const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: s.currentReplyTarget?.rootMessageId, replyTargetTurnId: s.currentReplyTarget?.turnId, currentTurnId }); const dispatch = (content: string, msgType: string): Promise => sendTarget.mode === 'plain' - ? sendMessage(appId, sendTarget.chatId, content, msgType) - : replyMessage(appId, sendTarget.rootMessageId, content, msgType, true); + ? sendMessage(appId, sendTarget.chatId, content, msgType, undefined, hookContext) + : replyMessage(appId, sendTarget.rootMessageId, content, msgType, true, undefined, hookContext); const recordBridgeSendMarker = (sentAtMs: number, messageId: string, sentContent: string): void => { try { const markerDir = join(resolveDataDir(), 'turn-sends'); @@ -3102,20 +3110,24 @@ async function cmdSend(rest: string[]): Promise { }); let primaryQuotedId: string | null = null; const dispatchPrimary = async (content: string, msgType: string): Promise => { - if (quoteTargetId) { - try { - const id = await replyMessage(appId, quoteTargetId, content, msgType, false); - primaryQuotedId = quoteTargetId; - return id; - } catch (err: any) { - if (err instanceof MessageWithdrawnError) { - console.error(`引用目标 ${quoteTargetId} 已撤回,改为普通发送`); - return sendMessage(appId, targetChatId, content, msgType); - } - throw err; - } - } - return dispatch(content, msgType); + const result = await dispatchPrimaryMessage( + { sendMessage, replyMessage }, + { + appId, + targetChatId, + quoteTargetId, + content, + msgType, + hookContext, + MessageWithdrawnError, + dispatch, + onQuoteWithdrawn: (id) => { + console.error(`引用目标 ${id} 已撤回,改为普通发送`); + }, + }, + ); + primaryQuotedId = result.primaryQuotedId; + return result.messageId; }; try { diff --git a/src/cli/send-dispatch.ts b/src/cli/send-dispatch.ts new file mode 100644 index 00000000..d39dc561 --- /dev/null +++ b/src/cli/send-dispatch.ts @@ -0,0 +1,81 @@ +export type SendMessageFn = ( + larkAppId: string, + chatId: string, + content: string, + msgType?: string, + uuid?: string, + hookContext?: Record, +) => Promise; + +export type ReplyMessageFn = ( + larkAppId: string, + messageId: string, + content: string, + msgType?: string, + replyInThread?: boolean, + uuid?: string, + hookContext?: Record, +) => Promise; + +export type DispatchPrimaryDeps = { + sendMessage: SendMessageFn; + replyMessage: ReplyMessageFn; +}; + +export type DispatchPrimaryOptions = { + appId: string; + targetChatId: string; + quoteTargetId: string | null | undefined; + content: string; + msgType: string; + hookContext: Record; + MessageWithdrawnError: new (...args: any[]) => Error; + dispatch: (content: string, msgType: string) => Promise; + onQuoteWithdrawn?: (messageId: string) => void; +}; + +export type DispatchPrimaryResult = { + messageId: string; + primaryQuotedId: string | null; +}; + +export async function dispatchPrimaryMessage( + deps: DispatchPrimaryDeps, + opts: DispatchPrimaryOptions, +): Promise { + if (!opts.quoteTargetId) { + return { + messageId: await opts.dispatch(opts.content, opts.msgType), + primaryQuotedId: null, + }; + } + + try { + const messageId = await deps.replyMessage( + opts.appId, + opts.quoteTargetId, + opts.content, + opts.msgType, + false, + undefined, + opts.hookContext, + ); + return { messageId, primaryQuotedId: opts.quoteTargetId }; + } catch (err: any) { + if (err instanceof opts.MessageWithdrawnError) { + opts.onQuoteWithdrawn?.(opts.quoteTargetId); + return { + messageId: await deps.sendMessage( + opts.appId, + opts.targetChatId, + opts.content, + opts.msgType, + undefined, + opts.hookContext, + ), + primaryQuotedId: null, + }; + } + throw err; + } +} diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 75235787..70d3bc20 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -10,6 +10,7 @@ import * as oncallStore from '../services/oncall-store.js'; import * as brandStore from '../services/brand-store.js'; import * as cardPrefsStore from '../services/card-prefs-store.js'; import * as grantPrefsStore from '../services/grant-prefs-store.js'; +import { findConfigField, applyConfigField } from '../services/bot-config-store.js'; import * as chatFirstSeenStore from '../services/chat-first-seen-store.js'; import * as scheduler from './scheduler.js'; import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession } from './worker-pool.js'; @@ -40,7 +41,7 @@ import { getBotName, type SessionRow, } from './dashboard-rows.js'; -import { getBotBrand } from '../bot-registry.js'; +import { getBotBrand, getBot } from '../bot-registry.js'; import type { ScheduledTask, ParsedSchedule } from '../types.js'; export interface IpcServerHandle { @@ -607,6 +608,8 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { const { defaultOncall, autoboundChats } = oncallStore.getBotDefaultOncall(cachedLarkAppId); const cardPrefs = cardPrefsStore.getBotCardPrefs(cachedLarkAppId); const grantPrefs = grantPrefsStore.getBotGrantPrefs(cachedLarkAppId); + let p2pMode: 'thread' | 'chat' = 'thread'; + try { if (getBot(cachedLarkAppId).config.p2pMode === 'chat') p2pMode = 'chat'; } catch { /* default thread */ } jsonRes(res, 200, { larkAppId: cachedLarkAppId, botName: getBotName(), @@ -622,6 +625,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { regularGroupReplyInThread: cardPrefs.regularGroupReplyInThread, restrictGrantCommands: grantPrefs.restrictGrantCommands, messageQuotaDefaultLimit: grantPrefs.messageQuotaDefaultLimit, + p2pMode, }); }); @@ -697,6 +701,25 @@ ipcRoute('PUT', '/api/bot-brand-label', async (req, res) => { jsonRes(res, 200, { ok: true, brandLabel: r.brandLabel }); }); +// Per-bot 私聊单聊模式 p2pMode。Body `{ p2pMode: 'chat' | 'thread' }`: +// • 'chat' → 私聊走扁平连续 chat-scope 会话 +// • 'thread'(默认) → 清回每条 DM 独立 thread-scope 会话 +// 走 applyConfigField(与 /botconfig 同一写盘 + 热更新路径),保证一致。 +ipcRoute('PUT', '/api/bot-p2p-mode', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let body: { p2pMode?: unknown }; + try { body = await readJsonBody<{ p2pMode?: unknown }>(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + + const spec = findConfigField('p2pMode'); + if (!spec) return jsonRes(res, 500, { ok: false, error: 'spec_missing' }); + // 只有 'chat' 有意义;其它(含 'thread')一律清回默认,bots.json 保持干净。 + const value = body.p2pMode === 'chat' ? 'chat' : null; + const r = await applyConfigField(cachedLarkAppId, spec, value); + if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); + jsonRes(res, 200, { ok: true, p2pMode: value ?? 'thread' }); +}); + ipcRoute('PUT', '/api/bot-default-oncall', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); let body: { enabled?: unknown; workingDir?: unknown }; diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index 36ed15dc..deedb1f9 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -1,5 +1,6 @@ import { Cron } from 'croner'; import * as scheduleStore from '../services/schedule-store.js'; +import { emitHookEvent } from '../services/hook-runner.js'; import { logger } from '../utils/logger.js'; import { dashboardEventBus } from './dashboard-events.js'; import type { ScheduledTask, ParsedSchedule } from '../types.js'; @@ -19,6 +20,22 @@ const ONESHOT_GRACE_SECONDS = 120; // one-shots fire even if <2min late const MIN_GRACE_SECONDS = 120; // catch-up window lower bound const MAX_GRACE_SECONDS = 2 * 60 * 60; // catch-up window upper bound (2h) +function emitScheduleFiredHook(task: ScheduledTask, status: 'ok' | 'error', error?: unknown): void { + emitHookEvent('schedule.fired', { + id: task.id, + name: task.name, + schedule: task.schedule, + status, + error: error ? (error instanceof Error ? error.message : String(error)) : undefined, + chatId: task.chatId, + rootMessageId: task.rootMessageId, + chatType: task.chatType, + scope: task.scope, + larkAppId: task.larkAppId, + runAt: Date.now(), + }); +} + export function setExecuteCallback(cb: (task: ScheduledTask) => Promise): void { executeCallback = cb; } @@ -356,6 +373,7 @@ async function tick(): Promise { type: 'schedule.fired', body: { id: taskId, runAt: Date.now(), status: 'ok' }, }); + emitScheduleFiredHook(task, 'ok'); }) .catch(err => { logger.error(`[scheduler] Task "${task.name}" failed: ${err.message}`); @@ -369,6 +387,7 @@ async function tick(): Promise { error: err instanceof Error ? err.message : String(err), }, }); + emitScheduleFiredHook(task, 'error', err); }); } } @@ -511,6 +530,7 @@ export function runNow(id: string): { ok: boolean; error?: string } { type: 'schedule.fired', body: { id, runAt: Date.now(), status: 'ok' }, }); + emitScheduleFiredHook(task, 'ok'); }, err => { const msg = err instanceof Error ? err.message : String(err); @@ -519,6 +539,7 @@ export function runNow(id: string): { ok: boolean; error?: string } { type: 'schedule.fired', body: { id, runAt: Date.now(), status: 'error', error: msg }, }); + emitScheduleFiredHook(task, 'error', err); }, ); return { ok: true }; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 14f8c3a8..4ba7dc2d 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -32,6 +32,7 @@ import { dashboardEventBus } from './dashboard-events.js'; import { composeRowFromActive } from './dashboard-rows.js'; import { publishAttentionPatch } from './session-activity.js'; import { knownBotOpenIdsFromCrossRef, type BotMentionEntry } from '../utils/bot-routing.js'; +import { emitSessionLifecycleHook, emitSessionStateTransitionHook } from '../services/session-lifecycle-hooks.js'; import type { CliId } from '../adapters/cli/types.js'; import type { DaemonToWorker, WorkerToDaemon, Session, DisplayMode } from '../types.js'; import { sessionKey, sessionAnchorId, type DaemonSession } from './types.js'; @@ -908,6 +909,7 @@ export async function closeSession( type: 'session.exited', body: { sessionId, reason: 'dashboard_close' }, }); + emitSessionLifecycleHook(ds, 'session.exit', { reason: 'dashboard_close' }); } } @@ -1323,6 +1325,10 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v type: 'session.spawned', body: { session: composeRowFromActive(ds) }, }); + emitSessionLifecycleHook(ds, 'session.start', { + reason: resume ? 'resume' : 'worker_spawn', + pid: worker.pid ?? null, + }); } // ─── Shared worker IPC handler ────────────────────────────────────────────── @@ -1555,6 +1561,10 @@ function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void { }, }, }); + emitSessionStateTransitionHook(ds, prevStatus, ds.lastScreenStatus, { + source: 'screen_update', + content: msg.content, + }); } // Bot opted out of the streaming card — dashboard SSE above already got @@ -1651,8 +1661,14 @@ function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void { // reflect previous turn's content. Next 10s cycle picks up fresh content. if (ds.streamCardPending) break; ds.currentImageKey = msg.imageKey; + const prevStatus = ds.lastScreenStatus; updateUsageLimitState(ds, msg.usageLimit); ds.lastScreenStatus = (msg.usageLimit ?? ds.usageLimit) ? 'limited' : msg.status; + emitSessionStateTransitionHook(ds, prevStatus, ds.lastScreenStatus, { + source: 'screenshot_uploaded', + imageKey: msg.imageKey, + content: ds.lastScreenContent ?? '', + }); persistStreamCardState(ds); if ((ds.displayMode ?? 'hidden') !== 'screenshot') break; if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !ds.workerPort) break; @@ -1690,6 +1706,18 @@ function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void { ds.tuiPromptOptions = msg.options; ds.tuiPromptMultiSelect = msg.multiSelect; ds.tuiToggledIndices = []; + emitSessionLifecycleHook(ds, 'session.requires_attention', { + reason: 'tui_prompt', + description: msg.description, + optionsCount: msg.options.length, + optionsPreview: msg.options.slice(0, 5).map(option => ({ + text: option.text, + label: option.label, + type: option.type, + selected: option.selected, + })), + multiSelect: msg.multiSelect, + }); const prevTuiTurnTitle = ds.currentTurnTitle; ds.currentTurnTitle = msg.description; // store for card PATCH on toggle if (prevTuiTurnTitle !== ds.currentTurnTitle) { @@ -1820,6 +1848,10 @@ function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void { case 'user_notify': { logger.warn(`[${t}] Worker user_notify: ${msg.message}`); + emitSessionLifecycleHook(ds, 'session.requires_attention', { + reason: 'user_notify', + message: msg.message, + }); try { await scopedReply(msg.message, 'text', msg.turnId); } catch (err: any) { @@ -1894,6 +1926,10 @@ function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void { reason: code === 0 ? 'graceful' : `exit_code_${code}`, }, }); + emitSessionLifecycleHook(ds, 'session.exit', { + reason: code === 0 ? 'graceful' : `exit_code_${code}`, + code, + }); } }); } @@ -2202,6 +2238,11 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata type: 'session.spawned', body: { session: composeRowFromActive(ds) }, }); + emitSessionLifecycleHook(ds, 'session.start', { + reason: opts?.restoredFromMetadata ? 'adopt_restore' : 'adopt', + pid: worker.pid ?? null, + adoptedFrom: adopted.tmuxTarget, + }); } // ─── Kill stale PIDs ──────────────────────────────────────────────────────── diff --git a/src/daemon.ts b/src/daemon.ts index 3eb9fb59..e121c782 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -16,6 +16,8 @@ import * as chatFirstSeenStore from './services/chat-first-seen-store.js'; import { ensureDefaultOncallBound } from './services/oncall-store.js'; import * as scheduleStore from './services/schedule-store.js'; import * as messageQueue from './services/message-queue.js'; +import { emitHookEvent, HOOK_EVENTS, type HookEvent } from './services/hook-runner.js'; +import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js'; import { parseEventMessage, resolveNonsupportMessage, stripLeadingMentions, type MessageResource } from './im/lark/message-parser.js'; import { expandMergeForward } from './im/lark/merge-forward.js'; import { buildQuoteHint } from './im/lark/quote-hint.js'; @@ -358,6 +360,11 @@ async function sessionReply(anchor: string, content: string, msgType: string = ' } const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId; if (!appId) throw new Error('No bot configured'); + const hookContext = ds ? { + sessionId: ds.session.sessionId, + scope: ds.scope, + anchor: sessionAnchorId(ds), + } : undefined; // Chat-scope: post a plain message to the chat. No reply_in_thread → keeps // the conversation flat in 普通群. The card layer carries chatId in its button @@ -379,20 +386,20 @@ async function sessionReply(anchor: string, content: string, msgType: string = ' const fresh = readSessionFreshFromDisk(ds.session.sessionId, ds.larkAppId); if (fresh) syncReplyTargetState(ds, fresh); const target = resolveSessionReplyTarget(ds, turnId); - if (target.mode === 'thread') return replyMessage(appId, target.rootMessageId, content, msgType, true); + if (target.mode === 'thread') return replyMessage(appId, target.rootMessageId, content, msgType, true, undefined, hookContext); if (ds.session.rootMessageId) { const mode = await getChatMode(appId, chatId, { forceRefresh: true }); if (mode === 'topic') { logger.warn(`[routing] Chat-scope session ${ds.session.sessionId.substring(0, 8)} is now topic-mode; replying in original thread ${ds.session.rootMessageId.substring(0, 12)}`); - return replyMessage(appId, ds.session.rootMessageId, content, msgType, true); + return replyMessage(appId, ds.session.rootMessageId, content, msgType, true, undefined, hookContext); } } } - return sendMessage(appId, chatId, content, msgType); + return sendMessage(appId, chatId, content, msgType, undefined, hookContext); } // Thread-scope (or unknown / legacy): reply in thread. - return replyMessage(appId, anchor, content, msgType, true); + return replyMessage(appId, anchor, content, msgType, true, undefined, hookContext); } async function revokeQuotaGrant( @@ -1733,6 +1740,33 @@ ipcRoute('POST', '/api/asks', async (req, res) => { return jsonRes(res, 200, result); }); +// ─── hooks emit 转发端点 ──────────────────────────────────────────────────── +// CLI side(botmux send 等)调用 emitHookEvent 时,把事件转发到 daemon 这条 +// 接口;daemon 在自己的长寿命事件循环里负责 spawn hook、跑 timeout、超时杀 +// 整个进程组。短命 CLI 进程的 timer.unref 会让超时承诺失效、跑飞的 hook 留 +// 孤儿,让 daemon 接管根治这一缺口。daemon 进程自身不带 BOTMUX_SESSION_ID +// 环境变量,所以这里调 emitHookEvent 不会再触发转发回退(无递归)。 +ipcRoute('POST', '/api/hooks/emit', async (req, res) => { + let raw: unknown; + try { + raw = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + if (!raw || typeof raw !== 'object') { + return jsonRes(res, 400, { ok: false, error: 'bad_body' }); + } + const { event, payload } = raw as { event?: unknown; payload?: unknown }; + if (typeof event !== 'string' || !(HOOK_EVENTS as readonly string[]).includes(event)) { + return jsonRes(res, 400, { ok: false, error: 'bad_event' }); + } + if (!payload || typeof payload !== 'object') { + return jsonRes(res, 400, { ok: false, error: 'bad_payload' }); + } + emitHookEvent(event as HookEvent, payload as Record); + return jsonRes(res, 202, { ok: true }); +}); + // ─── adopt-session 查询端点 ─────────────────────────────────────────────────── // CLI side(botmux hook)通过祖先 PID 匹配 adopt 会话,路由 askUserQuestion。 // GET /api/adopt-session/:pid — 返回该 pid 对应的 adopt 会话路由信息。 @@ -1927,6 +1961,18 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise { const senderUnionId: string | undefined = data.sender?.sender_id?.union_id; const botCfg = getBot(larkAppId).config; logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`); + emitHookEvent('topic.new', { + larkAppId, + chatId, + chatType, + scope, + anchor, + messageId, + senderOpenId, + senderType: parsed.senderType, + msgType: parsed.msgType, + content, + }); if (parseWorkflowCommand(cmdContent)) { if (await replyGrantRestrictionIfNeeded(larkAppId, chatId, senderOpenId, anchor, '/workflow')) { @@ -2431,6 +2477,22 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise : ''; const promptContent = buildQuoteHint(parsed, scope, anchor) + botSenderPrefix + parsed.content; + const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId)); + emitHookEvent('thread.reply', { + larkAppId, + chatId: ctxChatId, + chatType: ctxChatType, + scope, + anchor, + messageId: parsed.messageId, + rootId: parsed.rootId, + parentId: parsed.parentId, + senderOpenId: senderOpenIdForPrefix, + senderType: parsed.senderType, + msgType: parsed.msgType, + sessionId: existingHookSession?.session.sessionId, + content: parsed.content, + }); if (isForeignBot) { logger.info( `[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` + @@ -3190,6 +3252,7 @@ export async function startDaemon(botIndex?: number): Promise { const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; + setSessionLifecycleShutdown(true); logger.info(`Daemon shutting down... (active: ${getActiveCount()})`); scheduler.stopScheduler(); for (const watcher of workflowEventWatchers.values()) watcher.close(); diff --git a/src/dashboard.ts b/src/dashboard.ts index f7fc9a65..9998e39c 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -855,6 +855,7 @@ const server = createServer(async (req, res) => { regularGroupReplyInThread: j.regularGroupReplyInThread === true, restrictGrantCommands: j.restrictGrantCommands === true, messageQuotaDefaultLimit: typeof j.messageQuotaDefaultLimit === 'number' ? j.messageQuotaDefaultLimit : null, + p2pMode: j.p2pMode === 'chat' ? 'chat' : 'thread', }; } catch (e: any) { return { larkAppId: d.larkAppId, botName: d.botName, online: true, error: e?.message ?? String(e) }; @@ -915,6 +916,25 @@ const server = createServer(async (req, res) => { return; } + // PUT /api/bots/:appId/p2p-mode — proxy to that bot's daemon. Body + // `{ p2pMode: 'chat' | 'thread' }` ('chat' = flat continuous DM session; + // anything else clears back to the per-message thread default). + let mBotP2pMode: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotP2pMode = url.pathname.match(/^\/api\/bots\/([^/]+)\/p2p-mode$/))) { + const appId = decodeURIComponent(mBotP2pMode[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-p2p-mode`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // PUT /api/bots/:appId/grant-prefs — proxy to that bot's daemon. Body carries // any subset of `{ restrictGrantCommands?: boolean, messageQuotaDefaultLimit?: number|null }`. let mBotGrantPrefs: RegExpMatchArray | null; diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index 2002e24b..1e8af004 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -204,7 +204,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) {
${renderRoleSection(b)}
-
${renderCardBehaviorSection(b)}${renderBrandSection(b)}
+
${renderCardBehaviorSection(b)}${renderP2pModeSection(b)}${renderBrandSection(b)}
${renderGrantSection(b)}
`; @@ -297,6 +297,28 @@ export async function renderBotDefaultsPage(root: HTMLElement) { `; } + // 私聊单聊模式 p2pMode(thread | chat)。Select 一改即 PUT + // /api/bots/:appId/p2p-mode(走 applyConfigField,与 /botconfig 同路径)。 + function renderP2pModeSection(b: any): string { + const mode: string = b.p2pMode === 'chat' ? 'chat' : 'thread'; + return `
+

${t('botDefaults.sectionP2p')}

+
+ + ${t('botDefaults.p2pHelp')} +
+ +
+
+
`; + } + function quotaStateLabel(quota: number | null): string { return quota == null ? t('botDefaults.quotaStateOff') @@ -591,6 +613,40 @@ export async function renderBotDefaultsPage(root: HTMLElement) { }); } + // ── 私聊单聊模式 p2pMode select ─────────────────────────────────────── + const p2pModeSel = card.querySelector('select[data-input=p2pMode]'); + const p2pStatusEl = card.querySelector('[data-p2p-status]'); + if (p2pModeSel && p2pStatusEl) { + p2pModeSel.addEventListener('change', async () => { + const mode = p2pModeSel.value === 'chat' ? 'chat' : 'thread'; + p2pStatusEl.textContent = ''; + p2pStatusEl.className = 'oncall-status'; + p2pModeSel.disabled = true; + try { + const r = await fetch(`/api/bots/${encodeURIComponent(appId)}/p2p-mode`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ p2pMode: mode }), + }); + const body = await r.json().catch(() => ({})); + if (r.ok && body.ok) { + p2pStatusEl.textContent = `✓ ${t('botDefaults.cardPrefSaved')}`; + p2pStatusEl.classList.add('hint-ok'); + const cached = cache.bots.find((bb: any) => bb.larkAppId === appId); + if (cached) cached.p2pMode = body.p2pMode === 'chat' ? 'chat' : 'thread'; + } else { + p2pStatusEl.textContent = `✗ ${body.error ?? r.status}`; + p2pStatusEl.classList.add('hint-warn-inline'); + } + } catch (e: any) { + p2pStatusEl.textContent = `✗ ${e?.message ?? e}`; + p2pStatusEl.classList.add('hint-warn-inline'); + } finally { + p2pModeSel.disabled = false; + } + }); + } + // ── Team role (one role per bot, cross-chat) ────────────────────────── const roleTextarea = card.querySelector('textarea[data-input=teamRole]'); const roleSaveBtn = card.querySelector('button[data-action=save-role]'); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 09ab99aa..9f50a27a 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -267,6 +267,11 @@ const zh: DashboardMessages = { 'botDefaults.autoStartTopicHelp': '开启后,在话题群里每当有人新开一个话题,机器人就会自动接入该话题、把首条消息当作任务开始处理,无需 @。仅对话题群生效,普通群不受影响。', 'botDefaults.regularGroupThread': '普通群回复开话题', 'botDefaults.regularGroupThreadHelp': '开启后,普通群里 @ 该 bot 的新顶层消息会在原消息下开话题回复;未开启时保持整群会话。', + 'botDefaults.sectionP2p': '私聊会话模式', + 'botDefaults.p2pMode': '私聊单聊模式', + 'botDefaults.p2pThread': 'thread(每条 DM 独立会话,默认)', + 'botDefaults.p2pChat': 'chat(扁平连续单聊会话)', + 'botDefaults.p2pHelp': '私聊(1:1 DM)的会话方式:thread = 每条顶层消息各自起独立会话(官方默认,避免把对话堆进同一个 CLI 进程);chat = 整段 DM 共用一个连续会话、共享上下文(类 Hermes)。改后立即生效。', 'botDefaults.sectionGrant': '授权与额度', 'botDefaults.restrictGrant': '限制被授权人只能纯对话', 'botDefaults.restrictGrantHelp': '开启后,被 /grant 授权的人(owner 自己不受限)只能发普通对话,所有 slash 命令一律拦截:botmux 自带命令、透传命令、/workflow、/introduce、/t 以及 CLI 原生命令(/help 等)。形如 /path/to/file 的内容不会被误判。', @@ -742,6 +747,11 @@ const en: DashboardMessages = { 'botDefaults.autoStartTopicHelp': 'When enabled, in a topic group the bot automatically joins each newly opened topic and starts working on its first message, no @ needed. Topic groups only — regular groups are unaffected.', 'botDefaults.regularGroupThread': 'Thread replies in regular groups', 'botDefaults.regularGroupThreadHelp': 'When enabled, new top-level @mentions in regular groups open a topic under the original message; when off, regular groups keep the shared chat session behavior.', + 'botDefaults.sectionP2p': 'Private chat mode', + 'botDefaults.p2pMode': 'DM session mode', + 'botDefaults.p2pThread': 'thread (separate session per DM, default)', + 'botDefaults.p2pChat': 'chat (flat continuous session)', + 'botDefaults.p2pHelp': 'How 1:1 DMs are sessioned: thread = each top-level message starts its own session (the official default, keeps chatter out of one CLI process); chat = the whole DM shares one continuous session and context (Hermes-like). Takes effect immediately.', 'botDefaults.sectionGrant': 'Authorization & Quota', 'botDefaults.restrictGrant': 'Restrict grantees to plain conversation', 'botDefaults.restrictGrantHelp': 'When enabled, /grant-authorized users (the owner is exempt) can only send plain messages; every slash command is blocked: botmux built-in commands, passthrough commands, /workflow, /introduce, /t, and CLI-native commands (/help, etc.). Text like /path/to/file is not misclassified.', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2ef3c3c3..befd2aaa 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -256,6 +256,9 @@ export const messages: Record = { 'card.config.sec.security': '🔒 Security & grants', 'card.config.quota_label': 'Msg quota', 'card.config.quota_off': 'Unlimited', + 'card.config.p2p.placeholder': 'DM mode', + 'card.config.p2p.thread': '🧵 thread (separate session/DM)', + 'card.config.p2p.chat': '💬 chat (continuous session)', 'config.label.disableStreamingCard': 'Disable live card', 'config.label.writableTerminalLinkInCard': 'Writable terminal in card', 'config.label.privateCard': 'Private snapshot card', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4e677c93..19d5337e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -259,6 +259,9 @@ export const messages: Record = { 'card.config.sec.security': '🔒 安全 / 授权', 'card.config.quota_label': '消息额度', 'card.config.quota_off': '不限', + 'card.config.p2p.placeholder': '私聊模式', + 'card.config.p2p.thread': '🧵 thread(每条 DM 独立会话)', + 'card.config.p2p.chat': '💬 chat(连续单聊会话)', 'config.label.disableStreamingCard': '关闭实时卡片', 'config.label.writableTerminalLinkInCard': '卡内嵌可写终端', 'config.label.privateCard': '私有快照卡', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index 18bd7faa..4f44495a 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -70,6 +70,12 @@ export function buildConfigCard(data: ConfigCardData, locale?: Locale): string { runSelects.push(configSelect('lang', data.lang ?? CONFIG_UNSET, [{ text: def, value: CONFIG_UNSET }, { text: '中文 (zh)', value: 'zh' }, { text: 'English (en)', value: 'en' }], { action: 'config_set', field: 'lang', ...locVal })); + // 私聊单聊模式:thread(默认,每条 DM 独立会话)| chat(扁平连续会话)。thread 与 + // 未设等价,故 thread 选项用 unset 哨兵:选它即清字段、回默认,避免把字面 + // 'thread' 写进 bots.json(与 dashboard 下拉一致,/botconfig get 重启前后一致)。 + runSelects.push(configSelect(t('card.config.p2p.placeholder', undefined, locale), data.p2pMode === 'chat' ? 'chat' : CONFIG_UNSET, + [{ text: t('card.config.p2p.thread', undefined, locale), value: CONFIG_UNSET }, { text: t('card.config.p2p.chat', undefined, locale), value: 'chat' }], + { action: 'config_set', field: 'p2pMode', ...locVal })); elements.push({ tag: 'action', actions: runSelects }); // ── 布尔开关分组 ───────────────────────────────────────────────────────── diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index fcf6bce8..97b3197e 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -5,6 +5,7 @@ import { Client, LoggerLevel } from '@larksuiteoapi/node-sdk'; import { getBotClient, getAllBots, getBot } from '../../bot-registry.js'; import { loadBotConfigs } from '../../bot-registry.js'; import { config } from '../../config.js'; +import { emitHookEvent } from '../../services/hook-runner.js'; import { logger } from '../../utils/logger.js'; import { resolveUserToken } from '../../utils/user-token.js'; import { listObservedBots } from '../../services/observed-bots-store.js'; @@ -88,7 +89,7 @@ const LARK_CODE_MESSAGE_WITHDRAWN = 230011; * idempotencyKey here so retries don't re-send. Existing callers omit * the param and get exactly the pre-Step-6 behavior. */ -export async function sendMessage(larkAppId: string, chatId: string, content: string, msgType: string = 'text', uuid?: string): Promise { +export async function sendMessage(larkAppId: string, chatId: string, content: string, msgType: string = 'text', uuid?: string, hookContext?: Record): Promise { const c = getBotClient(larkAppId); const body = msgType === 'text' ? JSON.stringify({ text: content }) : content; @@ -118,6 +119,15 @@ export async function sendMessage(larkAppId: string, chatId: string, content: st const messageId = res.data?.message_id; if (!messageId) throw new Error('No message_id in response'); logger.info(`Sent message ${messageId} to chat ${chatId}`); + emitHookEvent('outbound.send', { + ...hookContext, + larkAppId, + chatId, + messageId, + msgType, + uuid, + content, + }); return messageId; } @@ -128,7 +138,7 @@ export async function sendMessage(larkAppId: string, chatId: string, content: st * spike report §1.4 for the reply-specific test results, including the * cross-parent dedupe behavior that informs the inputHash design. */ -export async function replyMessage(larkAppId: string, messageId: string, content: string, msgType: string = 'text', replyInThread: boolean = false, uuid?: string): Promise { +export async function replyMessage(larkAppId: string, messageId: string, content: string, msgType: string = 'text', replyInThread: boolean = false, uuid?: string, hookContext?: Record): Promise { const c = getBotClient(larkAppId); const body = msgType === 'text' ? JSON.stringify({ text: content }) : content; @@ -158,6 +168,16 @@ export async function replyMessage(larkAppId: string, messageId: string, content const replyId = res.data?.message_id; if (!replyId) throw new Error('No message_id in reply response'); logger.info(`Replied ${replyId} to message ${messageId} [msgType=${msgType}, replyInThread=${replyInThread}]`); + emitHookEvent('outbound.reply', { + ...hookContext, + larkAppId, + messageId, + replyId, + msgType, + replyInThread, + uuid, + content, + }); return replyId; } diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 2e558134..97805940 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -1079,14 +1079,23 @@ async function decideRoutingWithSource( ): Promise { const rootId: string | undefined = message.root_id; const threadId: string | undefined = message.thread_id; - if (rootId && threadId) return { scope: 'thread', anchor: rootId, source: 'real-thread' }; - const chatType: string = message.chat_type ?? 'group'; const messageId: string = message.message_id; const chatId: string = message.chat_id; - // 私聊:每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 Lark DM 的话题 - // 化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。 + // 私聊 chat 模式:整段 DM 一律折进同一个扁平 chat-scope 会话。必须先于下面的 + // real-thread(root_id+thread_id)分支判断 —— 否则用户在 DM 里"回复某条消息" + // 形成的 thread 形态消息会被提前分流到 thread-scope,破坏"连续单聊会话"语义 + // (典型触发:thread→chat 模式切换后回复旧 thread,或 Lark 给 DM 回复塞了 + // thread_id)。仅 p2p 且 p2pMode==='chat' 命中,群聊 / p2p 默认 thread 模式不受影响。 + if (chatType === 'p2p' && getBot(larkAppId)?.config?.p2pMode === 'chat') { + return { scope: 'chat', anchor: chatId, source: 'p2p' }; + } + + if (rootId && threadId) return { scope: 'thread', anchor: rootId, source: 'real-thread' }; + + // 私聊默认(thread 模式):每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 + // Lark DM 的话题化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。 if (chatType === 'p2p') { return { scope: 'thread', anchor: messageId, source: 'p2p' }; } @@ -1395,7 +1404,10 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin // route this message as if it were a brand-new thread seed so // handleNewTopic spawns a thread-scope session anchored at messageId. // Gate on ownsSession to avoid an API roundtrip on every fresh inbound. - if (routing.scope === 'chat' && ownsSession) { + // Skip p2p: a DM is always 'p2p' and can never be a topic group, so the + // check can only waste a forceRefresh roundtrip (relevant now that + // p2pMode==='chat' makes DMs land on chat-scope). + if (routing.scope === 'chat' && ownsSession && chatType !== 'p2p') { const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true }); if (freshMode === 'topic') { logger.info( diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index 23d249a8..09af2b80 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -63,6 +63,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'autoStartOnNewTopic', configKey: 'autoStartOnNewTopic', kind: 'boolean', effect: 'immediate', clearable: false, hint: '话题群每个新话题自动开工 on|off' }, { key: 'disableCliBypass', configKey: 'disableCliBypass', kind: 'boolean', effect: 'next-session', clearable: false, hint: '不加 CLI 审批/sandbox 绕过参数 on|off' }, { key: 'restrictGrantCommands', configKey: 'restrictGrantCommands', kind: 'boolean', effect: 'immediate', clearable: false, hint: '被授权人仅能纯对话、拦截斜杠命令 on|off' }, + { key: 'p2pMode', configKey: 'p2pMode', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['thread', 'chat'], hint: '私聊单聊模式 thread|chat;chat=扁平连续会话,thread/unset 回默认(每条 DM 独立会话)' }, ]; /** 大小写不敏感地按 key 找字段 spec。 */ @@ -262,6 +263,8 @@ export interface ConfigCardData { model: string | null; modelChoices: string[]; lang: string | null; + /** 私聊单聊模式 p2pMode('chat' | 'thread');null = 未设(默认 thread)。 */ + p2pMode: string | null; brandLabel: string | null; defaultWorkingDir: string | null; /** 入群主动开工首轮 prompt(autoStartOnGroupJoinPrompt)。 */ @@ -287,6 +290,7 @@ export function getConfigCardData(larkAppId: string, modelChoices: readonly stri model: cfg.model ?? null, modelChoices: [...modelChoices], lang: cfg.lang ?? null, + p2pMode: cfg.p2pMode ?? null, brandLabel: cfg.brandLabel ?? null, defaultWorkingDir: cfg.defaultWorkingDir ?? null, autoStartPrompt: cfg.autoStartOnGroupJoinPrompt ?? null, diff --git a/src/services/hook-runner.ts b/src/services/hook-runner.ts new file mode 100644 index 00000000..bd2de8db --- /dev/null +++ b/src/services/hook-runner.ts @@ -0,0 +1,433 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { config } from '../config.js'; +import { findOnlineDaemon } from '../utils/daemon-discovery.js'; +import { logger } from '../utils/logger.js'; + +export const HOOK_EVENTS = [ + 'topic.new', + 'thread.reply', + 'outbound.send', + 'outbound.reply', + 'schedule.fired', + 'session.start', + 'session.exit', + 'session.idle', + 'session.requires_attention', +] as const; + +export type HookEvent = typeof HOOK_EVENTS[number]; + +export type HookFilter = { + chatId?: string | string[]; + senderOpenId?: string | string[]; + sender_open_id?: string | string[]; +}; + +export type HookConfig = { + event: HookEvent; + command: string; + timeoutMs?: number; + filter?: HookFilter; + redact?: { + fullContentEvents?: HookEvent[]; + }; +}; + +export type HookPayload = Record & { + event: HookEvent; + chatId?: string; + senderOpenId?: string; + sender_open_id?: string; +}; + +export type ParsedHookCommand = { + file: string; + args: string[]; +}; + +export type HookRunResult = { + ok: boolean; + code?: number | null; + signal?: NodeJS.Signals | null; + timedOut?: boolean; + error?: string; +}; + +type RunHookCommandOptions = { + fireAndForget?: boolean; +}; + +const DEFAULT_TIMEOUT_MS = 5_000; +const CONTENT_PREVIEW_LIMIT = 600; +const CONTENT_FIELDS = ['content', 'message', 'description', 'finalOutput', 'lastScreenContent'] as const; + +let envHookCache: { raw: string; hooks: HookConfig[] } | null = null; +let fileHookCache: { path: string; mtimeMs: number; size: number; hooks: HookConfig[] } | null = null; + +function isHookEvent(value: unknown): value is HookEvent { + return typeof value === 'string' && (HOOK_EVENTS as readonly string[]).includes(value); +} + +function normalizeStringList(value: unknown): string[] | undefined { + if (typeof value === 'string' && value) return [value]; + if (Array.isArray(value)) { + const out = value.filter((v): v is string => typeof v === 'string' && v.length > 0); + return out.length > 0 ? out : undefined; + } + return undefined; +} + +function normalizeHookConfig(raw: unknown): HookConfig | null { + if (!raw || typeof raw !== 'object') return null; + const rec = raw as Record; + if (!isHookEvent(rec.event)) return null; + if (typeof rec.command !== 'string' || rec.command.trim().length === 0) return null; + + const hook: HookConfig = { + event: rec.event, + command: rec.command, + }; + if (typeof rec.timeoutMs === 'number' && Number.isFinite(rec.timeoutMs)) { + hook.timeoutMs = rec.timeoutMs; + } + if (rec.filter && typeof rec.filter === 'object') { + const filterRec = rec.filter as Record; + const filter: HookFilter = {}; + const chatId = normalizeStringList(filterRec.chatId); + const senderOpenId = normalizeStringList(filterRec.senderOpenId ?? filterRec.sender_open_id); + if (chatId) filter.chatId = chatId; + if (senderOpenId) filter.senderOpenId = senderOpenId; + if (filter.chatId || filter.senderOpenId) hook.filter = filter; + } + if (rec.redact && typeof rec.redact === 'object') { + const redactRec = rec.redact as Record; + const fullContentEventsRaw = Array.isArray(redactRec.fullContentEvents) + ? redactRec.fullContentEvents + : []; + const fullContentEvents = fullContentEventsRaw.filter(isHookEvent); + if (fullContentEvents.length > 0) { + hook.redact = { fullContentEvents }; + } + } + return hook; +} + +function readJsonHookArray(raw: string): HookConfig[] { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.map(normalizeHookConfig).filter((h): h is HookConfig => !!h); +} + +export function loadHookConfigs(opts: { + dataDir?: string; + env?: Pick; +} = {}): HookConfig[] { + const env = opts.env ?? process.env; + try { + if (env.BOTMUX_HOOKS_JSON) { + if (envHookCache?.raw === env.BOTMUX_HOOKS_JSON) return envHookCache.hooks; + const hooks = readJsonHookArray(env.BOTMUX_HOOKS_JSON); + envHookCache = { raw: env.BOTMUX_HOOKS_JSON, hooks }; + return hooks; + } + + const hooksPath = env.BOTMUX_HOOKS_FILE || join(opts.dataDir ?? config.session.dataDir, 'hooks.json'); + if (!existsSync(hooksPath)) return []; + const stats = statSync(hooksPath); + if ( + fileHookCache + && fileHookCache.path === hooksPath + && fileHookCache.mtimeMs === stats.mtimeMs + && fileHookCache.size === stats.size + ) { + return fileHookCache.hooks; + } + const hooks = readJsonHookArray(readFileSync(hooksPath, 'utf-8')); + fileHookCache = { path: hooksPath, mtimeMs: stats.mtimeMs, size: stats.size, hooks }; + return hooks; + } catch (err: any) { + logger.warn(`[hooks] Failed to load hook config: ${err?.message ?? String(err)}`); + return []; + } +} + +export function prepareHookPayload(hook: HookConfig, rawPayload: HookPayload): HookPayload { + const allowFullContent = !!hook.redact?.fullContentEvents?.includes(rawPayload.event); + const payload: HookPayload = { ...rawPayload }; + + for (const field of CONTENT_FIELDS) { + const value = payload[field]; + if (typeof value !== 'string') continue; + const lengthKey = `${field}Length`; + const truncatedKey = `${field}Truncated`; + payload[lengthKey] = value.length; + if (allowFullContent || value.length <= CONTENT_PREVIEW_LIMIT) { + payload[truncatedKey] = false; + continue; + } + payload[field] = value.slice(0, CONTENT_PREVIEW_LIMIT); + payload[truncatedKey] = true; + } + + // Redact nested option text/label. session.requires_attention emits this + // as `optionsPreview` (see worker-pool.ts tui_prompt case); keep `options` + // as an alias so callers using either name get the same treatment. + for (const arrayField of ['optionsPreview', 'options'] as const) { + const arrayValue = payload[arrayField]; + if (!Array.isArray(arrayValue)) continue; + payload[arrayField] = arrayValue.map(item => { + if (!item || typeof item !== 'object') return item; + const opt = { ...(item as Record) }; + for (const field of ['text', 'label'] as const) { + const v = opt[field]; + if (typeof v === 'string' && v.length > CONTENT_PREVIEW_LIMIT) { + opt[field] = v.slice(0, CONTENT_PREVIEW_LIMIT); + } + } + return opt; + }); + } + + return payload; +} + +export function parseHookCommand(command: string): ParsedHookCommand { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (const ch of command.trim()) { + if (escaping) { + current += ch; + escaping = false; + continue; + } + if (ch === '\\') { + escaping = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + current += ch; + } + + if (escaping) current += '\\'; + if (quote) throw new Error('Unterminated quote in hook command'); + if (current) tokens.push(current); + if (tokens.length === 0) throw new Error('Empty hook command'); + const [file, ...args] = tokens; + return { file, args }; +} + +function valueMatchesFilter(allowed: string | string[] | undefined, actual: string | undefined): boolean { + if (!allowed) return true; + if (!actual) return false; + const list = Array.isArray(allowed) ? allowed : [allowed]; + return list.includes(actual); +} + +export function filterMatches(filter: HookFilter | undefined, payload: HookPayload): boolean { + if (!filter) return true; + const senderOpenId = payload.senderOpenId ?? payload.sender_open_id; + return valueMatchesFilter(filter.chatId, payload.chatId) + && valueMatchesFilter(filter.senderOpenId ?? filter.sender_open_id, senderOpenId); +} + +function timeoutFor(hook: HookConfig): number { + if (typeof hook.timeoutMs === 'number' && hook.timeoutMs >= 0) return hook.timeoutMs; + return DEFAULT_TIMEOUT_MS; +} + +async function runHookCommand( + hook: HookConfig, + payload: HookPayload, + options: RunHookCommandOptions = {}, +): Promise { + let parsed: ParsedHookCommand; + try { + parsed = parseHookCommand(hook.command); + } catch (err: any) { + return { ok: false, error: err?.message ?? String(err) }; + } + + return new Promise((resolve) => { + let settled = false; + let timedOut = false; + let stderr = ''; + const child = spawn(parsed.file, parsed.args, { + shell: false, + stdio: ['pipe', 'ignore', 'pipe'], + // detached so we can kill the whole process group (grandchildren included) + detached: true, + env: { + // Minimal allowlist — avoids leaking secrets (LARK_APP_SECRET, API keys, etc.) + PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', + HOME: process.env.HOME ?? '', + TMPDIR: process.env.TMPDIR ?? '/tmp', + TEMP: process.env.TEMP, + TMP: process.env.TMP, + SHELL: process.env.SHELL ?? '/bin/sh', + USER: process.env.USER, + LOGNAME: process.env.LOGNAME, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + BOTMUX_HOOK_EVENT: payload.event, + }, + }); + if (options.fireAndForget) { + // Unref both the process handle and the stderr pipe. child.unref() alone + // still leaves piped stdio referenced, making short-lived CLI commands + // wait for hooks to finish. + child.unref(); + (child.stderr as any)?.unref?.(); + } + + const settle = (result: HookRunResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + + const timer = setTimeout(() => { + timedOut = true; + try { + if (child.pid !== undefined) { + try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); } + setTimeout(() => { + if (!settled && child.pid !== undefined) { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + } + }, 250).unref(); + } else { + child.kill('SIGTERM'); + } + } catch { /* process may already be gone */ } + // Actively settle — don't wait for 'close' which may never fire if a + // grandchild process holds the stderr pipe open. + settle({ ok: false, timedOut: true, code: null, signal: null, error: 'hook timed out' }); + }, timeoutFor(hook)); + if (options.fireAndForget) timer.unref(); + + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', chunk => { + stderr += String(chunk); + if (stderr.length > 2_000) stderr = stderr.slice(-2_000); + }); + + child.on('error', (err) => { + settle({ ok: false, timedOut, error: err.message }); + }); + + child.on('close', (code, signal) => { + settle({ + ok: code === 0 && !timedOut, + code, + signal, + timedOut, + error: code === 0 && !timedOut ? undefined : (stderr.trim() || `hook exited code=${code} signal=${signal ?? 'none'}`), + }); + }); + + child.stdin?.end(JSON.stringify(payload), () => { + if (options.fireAndForget) (child.stdin as any)?.unref?.(); + }); + }); +} + +export function emitHookEvent(event: HookEvent, body: Record = {}): void { + try { + const payload: HookPayload = { + ...body, + event, + emittedAt: new Date().toISOString(), + }; + + // CLI context: forward to the long-lived daemon so its event loop + // supervises the timeout/process-group kill. Short-lived `botmux send` + // can't enforce timeouts itself — fireAndForget unrefs the timer, so a + // runaway hook would survive as an orphan. The daemon stays alive, its + // timer fires reliably, and `process.kill(-pid)` cleans the whole group. + // Daemon process doesn't have BOTMUX_SESSION_ID set, so this gate + // naturally avoids recursive forward when emitHookEvent runs daemon-side. + if (process.env.BOTMUX_SESSION_ID && process.env.BOTMUX_LARK_APP_ID) { + void forwardEmitToDaemon(event, payload, process.env.BOTMUX_LARK_APP_ID); + return; + } + + const hooks = loadHookConfigs().filter(hook => hook.event === event && filterMatches(hook.filter, payload)); + if (hooks.length === 0) return; + + for (const [i, hook] of hooks.entries()) { + const hookPayload = prepareHookPayload(hook, payload); + const tag = `${event}[${i}] (${hook.command.slice(0, 60)})`; + void runHookCommand(hook, hookPayload, { fireAndForget: true }).then(result => { + if (!result.ok) { + logger.warn(`[hooks] ${tag} failed: ${result.error ?? `code=${result.code} signal=${result.signal ?? 'none'}`}`); + } else { + logger.debug(`[hooks] ${tag} completed`); + } + }).catch((err: any) => { + logger.warn(`[hooks] ${tag} crashed: ${err?.message ?? String(err)}`); + }); + } + } catch (err: any) { + logger.warn(`[hooks] Failed to emit ${event}: ${err?.message ?? String(err)}`); + } +} + +export function runHookCommandForTest(hook: HookConfig, payload: HookPayload): Promise { + return runHookCommand(hook, payload); +} + +const HOOK_FORWARD_FETCH_TIMEOUT_MS = 2_000; + +/** + * CLI-side: hand off hook emission to the daemon so timeout enforcement and + * process-group cleanup work. Best-effort — daemon unreachable / 4xx / 5xx + * just log and drop, hooks are best-effort by contract. + */ +async function forwardEmitToDaemon(event: HookEvent, payload: HookPayload, larkAppId: string): Promise { + try { + const daemon = findOnlineDaemon(larkAppId); + if (!daemon) { + logger.debug(`[hooks] CLI forward: no daemon for ${larkAppId}, dropping ${event}`); + return; + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), HOOK_FORWARD_FETCH_TIMEOUT_MS); + timer.unref(); + try { + const res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/hooks/emit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event, payload }), + signal: ctrl.signal, + }); + if (!res.ok) { + logger.warn(`[hooks] CLI forward ${event} → daemon: HTTP ${res.status}`); + } + } finally { + clearTimeout(timer); + } + } catch (err: any) { + logger.warn(`[hooks] CLI forward ${event} failed: ${err?.message ?? String(err)}`); + } +} diff --git a/src/services/session-lifecycle-hooks.ts b/src/services/session-lifecycle-hooks.ts new file mode 100644 index 00000000..5440cc18 --- /dev/null +++ b/src/services/session-lifecycle-hooks.ts @@ -0,0 +1,87 @@ +import type { StreamStatus } from '../types.js'; +import type { DaemonSession } from '../core/types.js'; +import { sessionAnchorId } from '../core/types.js'; +import { emitHookEvent, type HookEvent } from './hook-runner.js'; +import { logger } from '../utils/logger.js'; + +type SessionLifecycleEvent = Extract< + HookEvent, + 'session.start' | 'session.exit' | 'session.idle' | 'session.requires_attention' +>; + +const IDLE_DEDUP_WINDOW_MS = 10_000; + +let shutdownInProgress = false; +const lastIdleEmits = new Map(); + +export function setSessionLifecycleShutdown(value: boolean): void { + shutdownInProgress = value; +} + +function lifecyclePayload(ds: DaemonSession, body: Record): Record { + const initCliId = ds.initConfig && 'cliId' in ds.initConfig ? ds.initConfig.cliId : undefined; + return { + sessionId: ds.session.sessionId, + chatId: ds.chatId, + chatType: ds.chatType, + larkAppId: ds.larkAppId, + scope: ds.scope, + anchor: sessionAnchorId(ds), + title: ds.currentTurnTitle ?? ds.session.title, + cliId: ds.session.cliId ?? initCliId, + workingDir: ds.workingDir ?? ds.session.workingDir, + hasHistory: ds.hasHistory, + spawnedAt: ds.spawnedAt, + lastMessageAt: ds.lastMessageAt, + ...body, + }; +} + +export function emitSessionLifecycleHook( + ds: DaemonSession, + event: SessionLifecycleEvent, + body: Record = {}, +): boolean { + if (event === 'session.exit') { + // Prune dedup state to prevent unbounded Map growth + const prefix = `:${ds.session.sessionId}:`; + for (const key of lastIdleEmits.keys()) { + if (key.includes(prefix)) lastIdleEmits.delete(key); + } + if (shutdownInProgress) { + logger.debug(`[hooks] session.exit suppressed during daemon shutdown (session ${ds.session.sessionId})`); + return false; + } + } + + emitHookEvent(event, lifecyclePayload(ds, body)); + return true; +} + +export function emitSessionStateTransitionHook( + ds: DaemonSession, + prevState: StreamStatus | undefined, + newState: StreamStatus | undefined, + body: Record = {}, +): boolean { + if (!newState || prevState === newState) return false; + if (prevState !== 'idle' && newState !== 'idle') return false; + + const now = Date.now(); + const key = `session.idle:${ds.session.sessionId}:${newState}`; + const last = lastIdleEmits.get(key); + if (last !== undefined && now - last < IDLE_DEDUP_WINDOW_MS) return false; + lastIdleEmits.set(key, now); + + return emitSessionLifecycleHook(ds, 'session.idle', { + prevState, + newState, + transition: newState === 'idle' ? 'enter' : 'exit', + ...body, + }); +} + +export function __testOnly_resetSessionLifecycleHooks(): void { + shutdownInProgress = false; + lastIdleEmits.clear(); +} diff --git a/test/bot-registry-grant.test.ts b/test/bot-registry-grant.test.ts index 1ae7e873..4b457092 100644 --- a/test/bot-registry-grant.test.ts +++ b/test/bot-registry-grant.test.ts @@ -115,4 +115,17 @@ describe('bot-registry grant additions', () => { } expect(parseBotConfigsFromText(JSON.stringify([{ larkAppId: 'rg3', larkAppSecret: 's' }]))[0].regularGroupReplyInThread).toBeUndefined(); }); + + it('parses p2pMode only as literal chat (else undefined = thread default)', () => { + const cfgs = parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'p1', larkAppSecret: 's', p2pMode: 'chat' }, + { larkAppId: 'p2', larkAppSecret: 's', p2pMode: 'thread' }, + { larkAppId: 'p3', larkAppSecret: 's' }, + { larkAppId: 'p4', larkAppSecret: 's', p2pMode: 'invalid' }, + ])); + expect(cfgs[0].p2pMode).toBe('chat'); + expect(cfgs[1].p2pMode).toBeUndefined(); // 'thread' normalizes to undefined + expect(cfgs[2].p2pMode).toBeUndefined(); + expect(cfgs[3].p2pMode).toBeUndefined(); + }); }); diff --git a/test/cli-send-dispatch.test.ts b/test/cli-send-dispatch.test.ts new file mode 100644 index 00000000..c53b2bca --- /dev/null +++ b/test/cli-send-dispatch.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { dispatchPrimaryMessage } from '../src/cli/send-dispatch.js'; + +class MessageWithdrawnError extends Error {} + +describe('dispatchPrimaryMessage hook context wiring', () => { + const baseOptions = { + appId: 'cli_app', + targetChatId: 'oc_chat', + hookContext: { + sessionId: 'sid_1', + chatId: 'oc_chat', + rootMessageId: 'om_root', + title: 'Hook Context', + }, + MessageWithdrawnError, + }; + + it('passes hookContext when quote reply succeeds', async () => { + const replyMessage = vi.fn(async () => 'om_reply'); + const sendMessage = vi.fn(async () => 'om_send'); + + const result = await dispatchPrimaryMessage( + { replyMessage, sendMessage }, + { + ...baseOptions, + quoteTargetId: 'om_quote', + dispatch: vi.fn(async () => 'om_dispatch'), + content: '{"schema":"2.0"}', + msgType: 'interactive', + }, + ); + + expect(result).toEqual({ messageId: 'om_reply', primaryQuotedId: 'om_quote' }); + expect(replyMessage).toHaveBeenCalledWith( + 'cli_app', + 'om_quote', + '{"schema":"2.0"}', + 'interactive', + false, + undefined, + baseOptions.hookContext, + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('passes hookContext when withdrawn quote falls back to plain send', async () => { + const replyMessage = vi.fn(async () => { + throw new MessageWithdrawnError('withdrawn'); + }); + const sendMessage = vi.fn(async () => 'om_send'); + + const result = await dispatchPrimaryMessage( + { replyMessage, sendMessage }, + { + ...baseOptions, + quoteTargetId: 'om_quote', + dispatch: vi.fn(async () => 'om_dispatch'), + content: '{"zh_cn":{"content":[]}}', + msgType: 'post', + }, + ); + + expect(result).toEqual({ messageId: 'om_send', primaryQuotedId: null }); + expect(sendMessage).toHaveBeenCalledWith( + 'cli_app', + 'oc_chat', + '{"zh_cn":{"content":[]}}', + 'post', + undefined, + baseOptions.hookContext, + ); + }); +}); diff --git a/test/cli-send-hook-context.test.ts b/test/cli-send-hook-context.test.ts new file mode 100644 index 00000000..c950efec --- /dev/null +++ b/test/cli-send-hook-context.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliSource = readFileSync(join(__dirname, '..', 'src', 'cli.ts'), 'utf8'); + +describe('cmdSend hook context wiring', () => { + it('passes the current session id into outbound send/reply hooks', () => { + expect(cliSource).toContain('const hookContext = {'); + expect(cliSource).toMatch(/sendMessage\(appId,\s*sendTarget\.chatId,\s*content,\s*msgType,\s*undefined,\s*hookContext\)/); + expect(cliSource).toMatch(/replyMessage\(appId,\s*sendTarget\.rootMessageId,\s*content,\s*msgType,\s*true,\s*undefined,\s*hookContext\)/); + }); +}); diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 76e21b82..a37ec536 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -92,7 +92,7 @@ vi.mock('@larksuiteoapi/node-sdk', () => { // ─── Imports (must be after mocks) ────────────────────────────────────────── import { __resetAnchorQueues } from '../src/utils/anchor-serializer.js'; -import { __resetEventClaimsForTest, canOperate, canTalk, ensureBotOpenId, isBotMentioned, startLarkEventDispatcher, writeBotInfoFile, type EventHandlers } from '../src/im/lark/event-dispatcher.js'; +import { __resetEventClaimsForTest, canOperate, canTalk, decideRouting, ensureBotOpenId, isBotMentioned, startLarkEventDispatcher, writeBotInfoFile, type EventHandlers } from '../src/im/lark/event-dispatcher.js'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -115,6 +115,7 @@ function setupBotState(opts?: { regularGroupReplyInThread?: boolean; autoStartOnNewTopic?: boolean; chatReplyModes?: Record; + p2pMode?: 'thread' | 'chat'; }) { mockGetBot.mockReturnValue({ config: { @@ -127,6 +128,7 @@ function setupBotState(opts?: { regularGroupReplyInThread: opts?.regularGroupReplyInThread, autoStartOnNewTopic: opts?.autoStartOnNewTopic, chatReplyModes: opts?.chatReplyModes, + p2pMode: opts?.p2pMode, }, botOpenId: opts && 'botOpenId' in opts ? opts.botOpenId : MY_OPEN_ID, resolvedAllowedUsers: opts?.allowedUsers ?? [], @@ -222,6 +224,42 @@ function makeUserMessageEvent(opts: { // ─── Tests ────────────────────────────────────────────────────────────────── +describe('decideRouting — p2p p2pMode (thread | chat)', () => { + // Build a p2p DM message object (decideRouting takes message, not the full event). + const dm = (over: Record = {}) => ({ + message_id: 'msg-dm', chat_id: 'oc_dm', chat_type: 'p2p', + root_id: undefined, thread_id: undefined, ...over, + }); + + it('chat mode: top-level DM → flat chat-scope anchored on chatId', async () => { + setupBotState({ p2pMode: 'chat' }); + expect(await decideRouting(MY_APP_ID, dm())).toEqual({ scope: 'chat', anchor: 'oc_dm' }); + }); + + it('chat mode: DM reply carrying root_id+thread_id still folds into the SAME chat-scope session (regression — must not escape to thread-scope)', async () => { + setupBotState({ p2pMode: 'chat' }); + expect(await decideRouting(MY_APP_ID, dm({ root_id: 'root-dm', thread_id: 'root-dm' }))) + .toEqual({ scope: 'chat', anchor: 'oc_dm' }); + }); + + it('default (thread) mode: top-level DM → fresh thread-scope anchored on messageId', async () => { + setupBotState({}); + expect(await decideRouting(MY_APP_ID, dm())).toEqual({ scope: 'thread', anchor: 'msg-dm' }); + }); + + it('default (thread) mode: DM reply with root_id+thread_id threads into its session (real-thread, unchanged)', async () => { + setupBotState({}); + expect(await decideRouting(MY_APP_ID, dm({ root_id: 'root-dm', thread_id: 'root-dm' }))) + .toEqual({ scope: 'thread', anchor: 'root-dm' }); + }); + + it('p2pMode=chat does NOT leak into group routing (gate is p2p-only): group real-thread stays thread-scope', async () => { + setupBotState({ p2pMode: 'chat' }); + expect(await decideRouting(MY_APP_ID, { message_id: 'msg-g', chat_id: 'oc_g', chat_type: 'group', root_id: 'root-g', thread_id: 'root-g' })) + .toEqual({ scope: 'thread', anchor: 'root-g' }); + }); +}); + describe('isBotMentioned', () => { beforeEach(() => { setupBotState(); diff --git a/test/hook-runner-cache.test.ts b/test/hook-runner-cache.test.ts new file mode 100644 index 00000000..f2b964a5 --- /dev/null +++ b/test/hook-runner-cache.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('loadHookConfigs mtime cache', () => { + it('reuses file parse results until mtime or size changes', async () => { + vi.resetModules(); + + const reads: string[] = []; + const files = new Map([ + ['/tmp/hooks.json', JSON.stringify([{ event: 'topic.new', command: '/bin/echo one' }])], + ]); + let stat = { mtimeMs: 1000, size: files.get('/tmp/hooks.json')!.length }; + + vi.doMock('node:fs', () => ({ + existsSync: vi.fn((path: string) => files.has(path)), + readFileSync: vi.fn((path: string) => { + reads.push(path); + return files.get(path) ?? ''; + }), + statSync: vi.fn(() => stat), + })); + vi.doMock('../src/config.js', () => ({ + config: { session: { dataDir: '/tmp' } }, + })); + vi.doMock('../src/utils/logger.js', () => ({ + logger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }, + })); + + const { loadHookConfigs } = await import('../src/services/hook-runner.js'); + + expect(loadHookConfigs({ env: {} })).toEqual([{ event: 'topic.new', command: '/bin/echo one' }]); + + files.set('/tmp/hooks.json', JSON.stringify([{ event: 'thread.reply', command: '/bin/echo two' }])); + expect(loadHookConfigs({ env: {} })).toEqual([{ event: 'topic.new', command: '/bin/echo one' }]); + expect(reads).toEqual(['/tmp/hooks.json']); + + stat = { mtimeMs: 2000, size: files.get('/tmp/hooks.json')!.length }; + expect(loadHookConfigs({ env: {} })).toEqual([{ event: 'thread.reply', command: '/bin/echo two' }]); + expect(reads).toEqual(['/tmp/hooks.json', '/tmp/hooks.json']); + }); + + it('caches BOTMUX_HOOKS_JSON by raw env value', async () => { + vi.resetModules(); + + const { loadHookConfigs } = await import('../src/services/hook-runner.js'); + const env = { + BOTMUX_HOOKS_JSON: JSON.stringify([{ event: 'outbound.send', command: '/bin/echo one' }]), + }; + + const first = loadHookConfigs({ env }); + const second = loadHookConfigs({ env }); + + expect(second).toBe(first); + env.BOTMUX_HOOKS_JSON = JSON.stringify([{ event: 'outbound.reply', command: '/bin/echo two' }]); + expect(loadHookConfigs({ env })).toEqual([{ event: 'outbound.reply', command: '/bin/echo two' }]); + }); +}); diff --git a/test/hook-runner.test.ts b/test/hook-runner.test.ts new file mode 100644 index 00000000..4fc8640d --- /dev/null +++ b/test/hook-runner.test.ts @@ -0,0 +1,352 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + filterMatches, + loadHookConfigs, + parseHookCommand, + prepareHookPayload, + runHookCommandForTest, + type HookConfig, +} from '../src/services/hook-runner.js'; + +let tmpDir = ''; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'botmux-hooks-')); +}); + +afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = ''; +}); + +describe('parseHookCommand', () => { + it('splits command strings without invoking a shell', () => { + expect(parseHookCommand('/usr/bin/env node "two words"')).toEqual({ + file: '/usr/bin/env', + args: ['node', 'two words'], + }); + }); + + it('rejects empty or malformed command strings', () => { + expect(() => parseHookCommand('')).toThrow(/empty/i); + expect(() => parseHookCommand('node "unterminated')).toThrow(/unterminated/i); + }); +}); + +describe('loadHookConfigs', () => { + it('loads hooks from hooks.json under the data dir', () => { + const hooks: HookConfig[] = [ + { event: 'topic.new', command: '/bin/echo topic', timeoutMs: 1000 }, + ]; + writeFileSync(join(tmpDir, 'hooks.json'), JSON.stringify(hooks)); + + expect(loadHookConfigs({ dataDir: tmpDir, env: {} })).toEqual(hooks); + }); + + it('lets BOTMUX_HOOKS_JSON override hooks.json', () => { + writeFileSync(join(tmpDir, 'hooks.json'), JSON.stringify([ + { event: 'topic.new', command: '/bin/echo file' }, + ])); + + expect(loadHookConfigs({ + dataDir: tmpDir, + env: { + BOTMUX_HOOKS_JSON: JSON.stringify([ + { event: 'thread.reply', command: '/bin/echo env' }, + ]), + }, + })).toEqual([{ event: 'thread.reply', command: '/bin/echo env' }]); + }); + + it('drops invalid entries and keeps valid ones', () => { + writeFileSync(join(tmpDir, 'hooks.json'), JSON.stringify([ + { event: 'unknown', command: '/bin/echo no' }, + { event: 'outbound.send', command: '' }, + { event: 'outbound.reply', command: '/bin/echo ok', timeoutMs: -1 }, + ])); + + expect(loadHookConfigs({ dataDir: tmpDir, env: {} })).toEqual([ + { event: 'outbound.reply', command: '/bin/echo ok', timeoutMs: -1 }, + ]); + }); + + it('normalizes redact full-content allowlist entries', () => { + writeFileSync(join(tmpDir, 'hooks.json'), JSON.stringify([ + { + event: 'session.requires_attention', + command: '/bin/echo attention', + redact: { fullContentEvents: ['session.requires_attention', 'unknown'] }, + }, + ])); + + expect(loadHookConfigs({ dataDir: tmpDir, env: {} })).toEqual([ + { + event: 'session.requires_attention', + command: '/bin/echo attention', + redact: { fullContentEvents: ['session.requires_attention'] }, + }, + ]); + }); +}); + +describe('prepareHookPayload', () => { + it('truncates content-like fields by default and preserves length metadata', () => { + const longContent = 'x'.repeat(650); + + const payload = prepareHookPayload( + { event: 'session.idle', command: '/bin/echo idle' }, + { + event: 'session.idle', + content: longContent, + message: 'm'.repeat(601), + description: 'short', + }, + ); + + expect(payload.content).toHaveLength(600); + expect(payload.contentLength).toBe(650); + expect(payload.contentTruncated).toBe(true); + expect(payload.message).toHaveLength(600); + expect(payload.messageLength).toBe(601); + expect(payload.messageTruncated).toBe(true); + expect(payload.description).toBe('short'); + expect(payload.descriptionLength).toBe(5); + expect(payload.descriptionTruncated).toBe(false); + }); + + it('truncates long text/label in nested options array', () => { + const longText = 'o'.repeat(700); + const payload = prepareHookPayload( + { event: 'session.requires_attention', command: '/bin/echo' }, + { + event: 'session.requires_attention', + options: [ + { text: longText, selected: false }, + { label: longText, value: 'x' }, + { text: 'short', selected: true }, + ], + }, + ); + + const opts = payload['options'] as Array>; + expect((opts[0].text as string).length).toBe(600); + expect((opts[1].label as string).length).toBe(600); + expect(opts[2].text).toBe('short'); + }); + + it('truncates long text/label in the real optionsPreview field too', () => { + // Mirror the path actually emitted by worker-pool.ts tui_prompt + // (`optionsPreview: ...`) — the previous fix only covered the synthetic + // `options` alias, leaving the production emit shape unredacted. + const longText = 'p'.repeat(700); + const payload = prepareHookPayload( + { event: 'session.requires_attention', command: '/bin/echo' }, + { + event: 'session.requires_attention', + optionsPreview: [ + { text: longText, selected: false }, + { label: longText, type: 'choice' }, + { text: 'fine', selected: true }, + ], + }, + ); + + const opts = payload['optionsPreview'] as Array>; + expect((opts[0].text as string).length).toBe(600); + expect((opts[1].label as string).length).toBe(600); + expect(opts[2].text).toBe('fine'); + }); + + it('keeps full content for allowlisted events', () => { + const longContent = 'x'.repeat(650); + + const payload = prepareHookPayload( + { + event: 'session.requires_attention', + command: '/bin/echo attention', + redact: { fullContentEvents: ['session.requires_attention'] }, + }, + { + event: 'session.requires_attention', + content: longContent, + message: 'm'.repeat(601), + }, + ); + + expect(payload.content).toBe(longContent); + expect(payload.contentLength).toBe(650); + expect(payload.contentTruncated).toBe(false); + expect(payload.message).toBe('m'.repeat(601)); + expect(payload.messageLength).toBe(601); + expect(payload.messageTruncated).toBe(false); + }); +}); + +describe('filterMatches', () => { + it('matches chatId and senderOpenId filters', () => { + const payload = { event: 'thread.reply' as const, chatId: 'oc_1', senderOpenId: 'ou_1' }; + + expect(filterMatches({ chatId: 'oc_1', senderOpenId: 'ou_1' }, payload)).toBe(true); + expect(filterMatches({ chatId: ['oc_2', 'oc_1'] }, payload)).toBe(true); + expect(filterMatches({ senderOpenId: ['ou_2'] }, payload)).toBe(false); + expect(filterMatches({ chatId: 'oc_2' }, payload)).toBe(false); + }); + + it('treats absent filters as a match', () => { + expect(filterMatches(undefined, { event: 'schedule.fired', chatId: 'oc_1' })).toBe(true); + }); +}); + +describe('runHookCommandForTest', () => { + it('writes the JSON payload to stdin and resolves without shell expansion', async () => { + const script = join(tmpDir, 'stdin-writer.js'); + const output = join(tmpDir, 'payload.json'); + writeFileSync(script, ` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { input += chunk; }); + process.stdin.on('end', () => writeFileSync(process.argv[2], input)); + `); + + const result = await runHookCommandForTest( + { event: 'outbound.send', command: `${process.execPath} ${script} ${output}` }, + { event: 'outbound.send', chatId: 'oc_1', messageId: 'om_1' }, + ); + + expect(result.ok).toBe(true); + expect(JSON.parse(readFileSync(output, 'utf-8'))).toMatchObject({ + event: 'outbound.send', + chatId: 'oc_1', + messageId: 'om_1', + }); + }); + + it('does not leak parent secrets into hook env', async () => { + const script = join(tmpDir, 'env-dump.js'); + const output = join(tmpDir, 'env.json'); + writeFileSync(script, ` + import { writeFileSync } from 'node:fs'; + writeFileSync(process.argv[2], JSON.stringify(process.env)); + `); + + process.env.LARK_APP_SECRET = 'super-secret'; + process.env.GITHUB_TOKEN = 'ghp_secret'; + + await runHookCommandForTest( + { event: 'outbound.send', command: `${process.execPath} ${script} ${output}` }, + { event: 'outbound.send' }, + ); + + const env = JSON.parse(readFileSync(output, 'utf-8')) as Record; + expect(env.LARK_APP_SECRET).toBeUndefined(); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.BOTMUX_HOOK_EVENT).toBe('outbound.send'); + expect(env.PATH).toBeDefined(); + + delete process.env.LARK_APP_SECRET; + delete process.env.GITHUB_TOKEN; + }); + + it('reports spawn failures without throwing', async () => { + const result = await runHookCommandForTest( + { event: 'topic.new', command: '/definitely/not/a/command' }, + { event: 'topic.new' }, + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/spawn/i); + }); + + it('kills timed-out hook processes', async () => { + const script = join(tmpDir, 'hang.js'); + writeFileSync(script, 'setInterval(() => {}, 1000);'); + + const started = Date.now(); + const result = await runHookCommandForTest( + { event: 'schedule.fired', command: `${process.execPath} ${script}`, timeoutMs: 50 }, + { event: 'schedule.fired', status: 'ok' }, + ); + + expect(result.ok).toBe(false); + expect(result.timedOut).toBe(true); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('does not keep CLI-style emitHookEvent processes alive for running hooks', () => { + const started = Date.now(); + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '-e', + [ + "const { emitHookEvent } = await import('./src/services/hook-runner.ts');", + "emitHookEvent('outbound.send', { content: 'hello' });", + ].join('\n'), + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + BOTMUX_HOOKS_JSON: JSON.stringify([ + { event: 'outbound.send', command: '/bin/sleep 1', timeoutMs: 5000 }, + ]), + }, + timeout: 2500, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(Date.now() - started).toBeLessThan(900); + }); + + it('CLI context forwards to daemon instead of spawning locally', () => { + // Behavioural proof of the daemon-supervised path: when BOTMUX_SESSION_ID + // and BOTMUX_LARK_APP_ID are set (CLI session), emitHookEvent forwards to + // the daemon and does NOT spawn the hook locally. Here no daemon is + // running, so findOnlineDaemon returns null and the forward silently + // drops — the local-spawn marker file therefore must not appear. + const marker = join(tmpDir, 'local-spawn-touched'); + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '-e', + [ + "const { emitHookEvent } = await import('./src/services/hook-runner.ts');", + "emitHookEvent('outbound.send', { content: 'hi' });", + ].join('\n'), + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + BOTMUX_SESSION_ID: 'sid-forward-test', + BOTMUX_LARK_APP_ID: 'cli_no_such_daemon', + BOTMUX_HOOKS_JSON: JSON.stringify([ + { event: 'outbound.send', command: `/usr/bin/touch ${marker}`, timeoutMs: 5000 }, + ]), + }, + timeout: 5000, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + // Give any (unintended) spawned hook child a moment to land its file. + expect(existsSync(marker)).toBe(false); + }); +}); diff --git a/test/session-lifecycle-hooks.test.ts b/test/session-lifecycle-hooks.test.ts new file mode 100644 index 00000000..5cae23a7 --- /dev/null +++ b/test/session-lifecycle-hooks.test.ts @@ -0,0 +1,301 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { emitHookEventMock } = vi.hoisted(() => ({ + emitHookEventMock: vi.fn(), +})); + +vi.mock('../src/services/hook-runner.js', () => ({ + emitHookEvent: (...args: unknown[]) => emitHookEventMock(...args), +})); + +vi.mock('../src/im/lark/client.js', () => { + class MessageWithdrawnError extends Error { + constructor(id: string) { super(`withdrawn: ${id}`); this.name = 'MessageWithdrawnError'; } + } + return { + updateMessage: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + MessageWithdrawnError, + }; +}); + +vi.mock('../src/im/lark/card-builder.js', () => ({ + buildStreamingCard: vi.fn(() => '{"type":"streaming"}'), + buildSessionCard: vi.fn(() => '{"type":"session"}'), + buildTuiPromptCard: vi.fn(() => '{"type":"tui"}'), + buildTuiPromptResolvedCard: vi.fn(() => '{"type":"tui-resolved"}'), + getCliDisplayName: vi.fn(() => 'Claude'), +})); + +vi.mock('../src/bot-registry.js', () => ({ + getBot: vi.fn(() => ({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code' }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + })), + getAllBots: vi.fn(() => []), +})); + +vi.mock('../src/config.js', () => ({ + config: { + web: { externalHost: 'localhost' }, + session: { dataDir: '/tmp/test-sessions' }, + daemon: { backendType: 'tmux', cliId: 'claude-code' }, + }, +})); + +vi.mock('../src/services/session-store.js', () => ({ + closeSession: vi.fn(), + updateSession: vi.fn(), + updateSessionPid: vi.fn(), +})); + +vi.mock('../src/services/frozen-card-store.js', () => ({ + loadFrozenCards: vi.fn(() => new Map()), + saveFrozenCards: vi.fn(), +})); + +vi.mock('../src/core/session-manager.js', () => ({ + persistStreamCardState: vi.fn(), +})); + +vi.mock('../src/core/dashboard-events.js', () => ({ + dashboardEventBus: { publish: vi.fn() }, +})); + +vi.mock('../src/core/dashboard-rows.js', () => ({ + composeRowFromActive: vi.fn(), +})); + +vi.mock('../src/skills/installer.js', () => ({ + ensureSkills: vi.fn(), +})); + +vi.mock('../src/adapters/cli/registry.js', () => ({ + createCliAdapterSync: vi.fn(), +})); + +vi.mock('../src/adapters/cli/claude-code.js', () => ({ + claudeJsonlPathForSession: vi.fn(), +})); + +vi.mock('../src/adapters/backend/tmux-backend.js', () => ({ + TmuxBackend: class {}, +})); + +vi.mock('../src/utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@larksuiteoapi/node-sdk', () => ({ + Client: class { constructor() {} }, + WSClient: class { start() {} }, + EventDispatcher: class { register() {} }, + LoggerLevel: { info: 2 }, +})); + +import { + __testOnly_resetSessionLifecycleHooks, + emitSessionLifecycleHook, + emitSessionStateTransitionHook, + setSessionLifecycleShutdown, +} from '../src/services/session-lifecycle-hooks.js'; +import { initWorkerPool, __testOnly_setupWorkerHandlers } from '../src/core/worker-pool.js'; +import type { DaemonSession } from '../src/core/types.js'; + +function makeFakeWorker() { + const worker = new EventEmitter() as any; + worker.killed = false; + worker.send = vi.fn(); + worker.kill = vi.fn(); + worker.pid = 12345; + worker.stdout = new EventEmitter(); + worker.stderr = new EventEmitter(); + return worker; +} + +function makeDs(overrides?: Partial): DaemonSession { + return { + session: { + sessionId: 'sid-lifecycle-test', + rootMessageId: 'om_root', + chatId: 'oc_chat', + title: 'Lifecycle Test', + status: 'active', + createdAt: new Date('2026-05-27T00:00:00.000Z').toISOString(), + chatType: 'group', + cliId: 'claude-code', + workingDir: '/repo', + }, + worker: makeFakeWorker(), + workerPort: 9999, + workerToken: 'tok', + larkAppId: 'app_test', + chatId: 'oc_chat', + chatType: 'group', + scope: 'thread', + spawnedAt: 1234, + cliVersion: '1.0', + lastMessageAt: 5678, + hasHistory: false, + workingDir: '/repo', + displayMode: 'hidden', + streamCardId: 'om_card', + streamCardNonce: 'nonce', + lastScreenContent: '', + lastScreenStatus: 'working', + currentTurnTitle: 'Lifecycle Test', + ...overrides, + } as DaemonSession; +} + +async function flush(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + __testOnly_resetSessionLifecycleHooks(); +}); + +describe('session lifecycle hook helper', () => { + it('emits session.start payload with session context', () => { + emitSessionLifecycleHook(makeDs(), 'session.start', { reason: 'new_topic' }); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.start', expect.objectContaining({ + sessionId: 'sid-lifecycle-test', + chatId: 'oc_chat', + chatType: 'group', + larkAppId: 'app_test', + scope: 'thread', + anchor: 'om_root', + title: 'Lifecycle Test', + cliId: 'claude-code', + workingDir: '/repo', + reason: 'new_topic', + })); + }); + + it('deduplicates repeated session.idle transitions for 10 seconds', () => { + vi.useFakeTimers(); + const ds = makeDs(); + + emitSessionStateTransitionHook(ds, 'working', 'idle', { source: 'screen_update' }); + emitSessionStateTransitionHook(ds, 'working', 'idle', { source: 'screen_update' }); + expect(emitHookEventMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(10_001); + emitSessionStateTransitionHook(ds, 'working', 'idle', { source: 'screen_update' }); + expect(emitHookEventMock).toHaveBeenCalledTimes(2); + }); + + it('silences session.exit while daemon shutdown is active', () => { + setSessionLifecycleShutdown(true); + + emitSessionLifecycleHook(makeDs(), 'session.exit', { reason: 'daemon_shutdown' }); + + expect(emitHookEventMock).not.toHaveBeenCalled(); + }); + + it('prunes lastIdleEmits entries for the session on session.exit', () => { + vi.useFakeTimers(); + const ds = makeDs(); + + emitSessionStateTransitionHook(ds, 'working', 'idle', { source: 'screen_update' }); + expect(emitHookEventMock).toHaveBeenCalledTimes(1); + + // session.exit should prune dedup state + emitSessionLifecycleHook(ds, 'session.exit', { reason: 'exit_code_0' }); + + // After exit prune, re-idle for same session should fire again immediately + vi.advanceTimersByTime(0); + emitSessionStateTransitionHook(ds, 'working', 'idle', { source: 'screen_update' }); + // session.exit + second idle = 3 total calls + expect(emitHookEventMock).toHaveBeenCalledTimes(3); + }); +}); + +describe('worker-pool lifecycle hook integration', () => { + beforeEach(() => { + initWorkerPool({ + sessionReply: vi.fn(async () => 'om_reply'), + getSessionWorkingDir: () => '/repo', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + }); + + it('emits session.idle on screen_update status edges', async () => { + const worker = makeFakeWorker(); + const ds = makeDs({ worker, lastScreenStatus: 'working' }); + __testOnly_setupWorkerHandlers(ds, worker); + + worker.emit('message', { type: 'screen_update', content: 'ready', status: 'idle' }); + await flush(); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.idle', expect.objectContaining({ + sessionId: 'sid-lifecycle-test', + prevState: 'working', + newState: 'idle', + source: 'screen_update', + })); + }); + + it('reuses the idle transition helper for screenshot_uploaded status edges', async () => { + const worker = makeFakeWorker(); + const ds = makeDs({ worker, lastScreenStatus: 'working' }); + __testOnly_setupWorkerHandlers(ds, worker); + + worker.emit('message', { type: 'screenshot_uploaded', imageKey: 'img', status: 'idle' }); + await flush(); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.idle', expect.objectContaining({ + sessionId: 'sid-lifecycle-test', + prevState: 'working', + newState: 'idle', + source: 'screenshot_uploaded', + })); + }); + + it('emits session.requires_attention from tui_prompt and user_notify IPC', async () => { + const worker = makeFakeWorker(); + const ds = makeDs({ worker }); + __testOnly_setupWorkerHandlers(ds, worker); + + worker.emit('message', { + type: 'tui_prompt', + description: 'Approve command?', + options: [{ text: 'Yes', selected: false }], + multiSelect: false, + }); + worker.emit('message', { type: 'user_notify', message: 'Need manual input' }); + await flush(); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.requires_attention', expect.objectContaining({ + reason: 'tui_prompt', + description: 'Approve command?', + optionsCount: 1, + })); + expect(emitHookEventMock).toHaveBeenCalledWith('session.requires_attention', expect.objectContaining({ + reason: 'user_notify', + message: 'Need manual input', + })); + }); + + it('emits session.exit from worker process exit', () => { + const worker = makeFakeWorker(); + const ds = makeDs({ worker }); + __testOnly_setupWorkerHandlers(ds, worker); + + worker.emit('exit', 1); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.exit', expect.objectContaining({ + sessionId: 'sid-lifecycle-test', + reason: 'exit_code_1', + code: 1, + })); + }); +}); diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts new file mode 100644 index 00000000..4c5ad3ed --- /dev/null +++ b/test/session-lifecycle-start.test.ts @@ -0,0 +1,192 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { emitHookEventMock, forkMock, execSyncMock } = vi.hoisted(() => ({ + emitHookEventMock: vi.fn(), + forkMock: vi.fn(), + execSyncMock: vi.fn(), +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fork: (...args: unknown[]) => forkMock(...args), + execSync: (...args: unknown[]) => execSyncMock(...args), + }; +}); + +vi.mock('../src/services/hook-runner.js', () => ({ + emitHookEvent: (...args: unknown[]) => emitHookEventMock(...args), +})); + +vi.mock('../src/im/lark/client.js', () => { + class MessageWithdrawnError extends Error { + constructor(id: string) { super(`withdrawn: ${id}`); this.name = 'MessageWithdrawnError'; } + } + return { + updateMessage: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + MessageWithdrawnError, + }; +}); + +vi.mock('../src/im/lark/card-builder.js', () => ({ + buildStreamingCard: vi.fn(() => '{"type":"streaming"}'), + buildSessionCard: vi.fn(() => '{"type":"session"}'), + buildTuiPromptCard: vi.fn(() => '{"type":"tui"}'), + buildTuiPromptResolvedCard: vi.fn(() => '{"type":"tui-resolved"}'), + getCliDisplayName: vi.fn(() => 'Codex'), +})); + +vi.mock('../src/bot-registry.js', () => ({ + getBot: vi.fn(() => ({ + config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'codex' }, + resolvedAllowedUsers: [], + botOpenId: 'ou_bot', + botName: 'TestBot', + })), + getAllBots: vi.fn(() => []), +})); + +vi.mock('../src/config.js', () => ({ + config: { + web: { externalHost: 'localhost' }, + session: { dataDir: '/tmp/test-sessions' }, + daemon: { backendType: 'tmux', cliId: 'codex' }, + }, +})); + +vi.mock('../src/services/session-store.js', () => ({ + closeSession: vi.fn(), + updateSession: vi.fn(), + updateSessionPid: vi.fn(), +})); + +vi.mock('../src/services/frozen-card-store.js', () => ({ + loadFrozenCards: vi.fn(() => new Map()), + saveFrozenCards: vi.fn(), +})); + +vi.mock('../src/core/session-manager.js', () => ({ + persistStreamCardState: vi.fn(), +})); + +vi.mock('../src/core/dashboard-events.js', () => ({ + dashboardEventBus: { publish: vi.fn() }, +})); + +vi.mock('../src/core/dashboard-rows.js', () => ({ + composeRowFromActive: vi.fn(), +})); + +vi.mock('../src/skills/installer.js', () => ({ + ensureSkills: vi.fn(), + ensureAskSkill: vi.fn(), + removeGlobalBotmuxSkills: vi.fn(), +})); + +vi.mock('../src/adapters/cli/claude-code.js', () => ({ + claudeJsonlPathForSession: vi.fn(), +})); + +vi.mock('../src/adapters/backend/tmux-backend.js', () => ({ + TmuxBackend: class {}, +})); + +vi.mock('../src/utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@larksuiteoapi/node-sdk', () => ({ + Client: class { constructor() {} }, + WSClient: class { start() {} }, + EventDispatcher: class { register() {} }, + LoggerLevel: { info: 2 }, +})); + +import { __testOnly_resetSessionLifecycleHooks } from '../src/services/session-lifecycle-hooks.js'; +import { forkAdoptWorker, forkWorker, initWorkerPool } from '../src/core/worker-pool.js'; +import type { DaemonSession } from '../src/core/types.js'; + +function makeFakeWorker() { + const worker = new EventEmitter() as any; + worker.killed = false; + worker.send = vi.fn(); + worker.kill = vi.fn(); + worker.pid = 12345; + worker.stdout = new EventEmitter(); + worker.stderr = new EventEmitter(); + return worker; +} + +function makeDs(overrides?: Partial): DaemonSession { + return { + session: { + sessionId: 'sid-start-test', + rootMessageId: 'om_root', + chatId: 'oc_chat', + title: 'Start Test', + status: 'active', + createdAt: new Date('2026-05-27T00:00:00.000Z').toISOString(), + chatType: 'group', + workingDir: '/repo', + }, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: 'app_test', + chatId: 'oc_chat', + chatType: 'group', + scope: 'thread', + spawnedAt: 1234, + cliVersion: '1.0', + lastMessageAt: 5678, + hasHistory: false, + workingDir: '/repo', + ...overrides, + } as DaemonSession; +} + +beforeEach(() => { + vi.clearAllMocks(); + __testOnly_resetSessionLifecycleHooks(); + forkMock.mockImplementation(() => makeFakeWorker()); + initWorkerPool({ + sessionReply: vi.fn(async () => 'om_reply'), + getSessionWorkingDir: () => '/repo', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); +}); + +describe('session.start lifecycle integration', () => { + it('emits session.start after forkWorker spawns a worker', () => { + forkWorker(makeDs(), 'hello', false); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.start', expect.objectContaining({ + sessionId: 'sid-start-test', + reason: 'worker_spawn', + pid: 12345, + })); + }); + + it('emits session.start after forkAdoptWorker spawns an adopt worker', () => { + forkAdoptWorker(makeDs({ + adoptedFrom: { + tmuxTarget: 'bmx-deadbeef:0.0', + originalCliPid: 23456, + sessionId: 'codex-session', + cliId: 'codex', + cwd: '/repo', + }, + })); + + expect(emitHookEventMock).toHaveBeenCalledWith('session.start', expect.objectContaining({ + sessionId: 'sid-start-test', + reason: 'adopt', + adoptedFrom: 'bmx-deadbeef:0.0', + pid: 12345, + })); + }); +});