From c6fc17207d4fb25d73193162f057062350d2b351 Mon Sep 17 00:00:00 2001 From: "wanglina.uwjb" Date: Wed, 24 Jun 2026 11:11:33 +0800 Subject: [PATCH] WIP: local modifications --- src/adapters/backend/tmux-backend.ts | 33 ++++-- src/adapters/backend/types.ts | 14 +++ src/adapters/backend/zellij-backend.ts | 4 +- src/core/persistent-backend.ts | 5 + src/core/session-discovery.ts | 65 ++++++----- src/core/types.ts | 5 +- src/core/worker-pool.ts | 70 +++++++++--- src/daemon.ts | 29 +++-- src/im/lark/doc-comment.ts | 143 +++++++++++++++++++++---- src/im/lark/event-dispatcher.ts | 26 ++++- src/types.ts | 6 +- src/utils/markdown.ts | 27 +++++ src/worker.ts | 79 +++++++++++++- 13 files changed, 423 insertions(+), 83 deletions(-) create mode 100644 src/utils/markdown.ts diff --git a/src/adapters/backend/tmux-backend.ts b/src/adapters/backend/tmux-backend.ts index 5285201f6..83b058e00 100644 --- a/src/adapters/backend/tmux-backend.ts +++ b/src/adapters/backend/tmux-backend.ts @@ -19,6 +19,16 @@ import { logger } from '../../utils/logger.js'; */ const REDACTED_ENV_UNSET_CLAUSE = `unset ${REDACTED_CHILD_ENV_KEYS.join(' ')}`; +/** + * Build the shell `unset` clause for a pane, starting with the always-redacted + * keys and appending any session-specific keys (e.g. ANTHROPIC_* for ttadk). + * Key names are validated identifiers — no shell-escaping needed. + */ +export function buildUnsetClause(extraKeys?: string[]): string { + if (!extraKeys?.length) return REDACTED_ENV_UNSET_CLAUSE; + return `unset ${[...REDACTED_CHILD_ENV_KEYS, ...extraKeys].join(' ')}`; +} + /** * TmuxBackend — session backend using tmux for process persistence. * @@ -222,8 +232,8 @@ export class TmuxBackend implements SessionBackend { // through the bot while in this mode — type into the web terminal directly. const debugKeepShell = process.env.BOTMUX_DEBUG_KEEP_SHELL === '1'; const script = debugKeepShell - ? buildDebugKeepShellScript(shellSpec.shell) - : SHELL_WRAPPER_SCRIPT; + ? buildDebugKeepShellScript(shellSpec.shell, opts.unsetEnvKeys) + : buildShellWrapperScript(opts.unsetEnvKeys); if (debugKeepShell) { logger.info( `[tmux:${this.sessionName}] BOTMUX_DEBUG_KEEP_SHELL=1 — CLI exit will drop ` + @@ -577,19 +587,24 @@ export function buildBotmuxEnvAssignments( } /** - * Default wrapper script for ` -c`. Sees argv as: + * Build the shell wrapper script for ` -c`. Sees argv as: * $0 = '_' (placeholder), $1 = cwd, $2..N = KEY=VAL... bin args... * * The `cd` step makes the CLI's cwd survive a wayward `cd` in the user's * rcfile. The `unset` step removes bare creds the pane inherited from the tmux - * server's global env (REDACTED_ENV_UNSET_CLAUSE). The `exec /usr/bin/env` step - * injects botmux's per-bot/per-session overrides AFTER rcfile load so they - * can't be shadowed by leftover exports. + * server's global env (plus any session-specific keys, e.g. ANTHROPIC_* for + * ttadk). The `exec /usr/bin/env` step injects botmux's per-bot/per-session + * overrides AFTER rcfile load so they can't be shadowed by leftover exports. * * POSIX-syntax (works in bash/zsh/sh); fish/csh/nu users get remapped to * bash/zsh/sh by resolveUserShell() so they hit the same SCRIPT path. */ -export const SHELL_WRAPPER_SCRIPT = `cd -- "$1" && shift && ${REDACTED_ENV_UNSET_CLAUSE} && exec /usr/bin/env "$@"`; +export function buildShellWrapperScript(unsetKeys?: string[]): string { + return `cd -- "$1" && shift && ${buildUnsetClause(unsetKeys)} && exec /usr/bin/env "$@"`; +} + +/** Default wrapper script with only the always-redacted keys. */ +export const SHELL_WRAPPER_SCRIPT = buildShellWrapperScript(); /** * Debug variant of the wrapper script — same prelude, but the CLI runs as @@ -604,13 +619,13 @@ export const SHELL_WRAPPER_SCRIPT = `cd -- "$1" && shift && ${REDACTED_ENV_UNSET * safe for paths containing spaces or quotes. Caller has already verified * it via accessSync(). */ -export function buildDebugKeepShellScript(shellPath: string): string { +export function buildDebugKeepShellScript(shellPath: string, unsetKeys?: string[]): string { const safeShell = shellPath.replace(/'/g, `'\\''`); return [ 'cd -- "$1" && shift', // Same redaction as SHELL_WRAPPER_SCRIPT — so neither the CLI nor the // interactive debug shell that follows sees server/rcfile-inherited creds. - REDACTED_ENV_UNSET_CLAUSE, + buildUnsetClause(unsetKeys), '/usr/bin/env "$@"', `printf '\\n[botmux debug] CLI exited (status %d) — interactive shell active. Type exit to close the session.\\n' "$?" >&2`, `exec '${safeShell}' -i`, diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index d192dab4d..471ef0637 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -29,6 +29,20 @@ export interface SpawnOpts { * merges them into the child env. Already sanitized (see sanitizePerBotEnv). */ injectEnv?: Record; + /** + * Extra env keys to `unset` in the pane's shell wrapper (tmux/zellij only). + * + * Persistent backends (tmux, zellij) share a backing server whose global env + * is inherited by every new pane — even keys deleted from `env` leak through + * this vector. `unsetEnvKeys` tells the backend to extend the wrapper script's + * `unset` clause with these keys so the pane never sees them. + * + * Used when a gateway wrapper (e.g. ttadk) must control certain env vars + * itself and the daemon's own values would bypass the gateway. + * + * Ignored by pty/herdr backends (no shared server → `env` is authoritative). + */ + unsetEnvKeys?: string[]; } export interface SessionBackend { diff --git a/src/adapters/backend/zellij-backend.ts b/src/adapters/backend/zellij-backend.ts index a3b335c5b..e05968046 100644 --- a/src/adapters/backend/zellij-backend.ts +++ b/src/adapters/backend/zellij-backend.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SessionBackend, SpawnOpts, SessionProbe } from './types.js'; import { zellijEnv, probeZellijFunctional } from '../../setup/ensure-zellij.js'; -import { resolveUserShell, buildBotmuxEnvAssignments, SHELL_WRAPPER_SCRIPT } from './tmux-backend.js'; +import { resolveUserShell, buildBotmuxEnvAssignments, buildShellWrapperScript } from './tmux-backend.js'; import { logger } from '../../utils/logger.js'; /** @@ -316,7 +316,7 @@ export function buildLayoutString(bin: string, args: string[], opts: SpawnOpts): const shellSpec = resolveUserShell(); const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv); const paneArgs = [ - ...shellSpec.flags, '-c', SHELL_WRAPPER_SCRIPT, '_', + ...shellSpec.flags, '-c', buildShellWrapperScript(opts.unsetEnvKeys), '_', opts.cwd, ...envAssignments, bin, ...args, diff --git a/src/core/persistent-backend.ts b/src/core/persistent-backend.ts index 9cae05867..1882cbbe3 100644 --- a/src/core/persistent-backend.ts +++ b/src/core/persistent-backend.ts @@ -35,6 +35,11 @@ export function isSuspendableBackendType( * forked a worker, where initConfig is unset). */ export function getSessionPersistentBackendType(ds: DaemonSession): PersistentBackendType | undefined { + // Adopt sessions observe a user's external tmux/herdr/zellij session — they + // don't own a bmx-* backing session, so the persistent-backend probe/reap + // logic must not touch them (probing bmx- would find nothing and + // wrongly close a live adopt session as a "zombie"). + if (ds.adoptedFrom) return undefined; let backendType: BackendType | undefined = ds.initConfig?.backendType; if (!backendType) { backendType = config.daemon.backendType; diff --git a/src/core/session-discovery.ts b/src/core/session-discovery.ts index 79cba203a..fbf5bc581 100644 --- a/src/core/session-discovery.ts +++ b/src/core/session-discovery.ts @@ -9,6 +9,7 @@ import { readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs'; import { homedir, platform } from 'node:os'; import { basename, join } from 'node:path'; import type { CliId } from '../adapters/cli/types.js'; +import { logger } from '../utils/logger.js'; import { findCodexRolloutByPid } from '../services/codex-transcript.js'; import { findCocoSessionByPid } from '../services/coco-transcript.js'; import { findTraexRolloutByPid } from '../services/traex-transcript.js'; @@ -493,7 +494,13 @@ export function findUniqueClaudeSessionByCwd(cwd: string): { sessionId?: string; // Ignore malformed or concurrently rewritten metadata files. } } - if (matches.length !== 1) return undefined; + if (matches.length === 0) return undefined; + // When multiple sessions share the same cwd (e.g. ttadk wrappers creating + // multiple Claude instances), pick the most recently active one so the + // adopt bridge has the best chance of finding the right transcript. + if (matches.length > 1) { + matches.sort((a, b) => (b.updatedAt ?? b.startedAt ?? 0) - (a.updatedAt ?? a.startedAt ?? 0)); + } return matches[0]; } @@ -659,46 +666,44 @@ export function discoverAdoptableSessions(filterCliId?: CliId): AdoptableSession // 3b. Filter by CLI type if requested if (filterCliId && match.cliId !== filterCliId) continue; + // 3c. If findCliProcess matched a COMM_ARGV_LAUNCHERS wrapper (e.g. node/ttadk + // wrapping claude), resolve the real CLI PID underneath it. findCliProcess uses + // cliIdFromCommArgv which can match the wrapper by argv; findLaunchedCliPid + // does comm-only BFS from the wrapper's children to find the actual CLI binary. + let cliPid = match.pid; + const matchComm = readComm(match.pid); + if (matchComm && COMM_ARGV_LAUNCHERS.has(matchComm)) { + const realPid = findLaunchedCliPid(match.pid, match.cliId); + if (realPid) cliPid = realPid; + } + // 4. Read CLI working directory (Linux: /proc; macOS: lsof) - const cwd = readCwd(match.pid); + const cwd = readCwd(cliPid); if (!cwd) continue; // 5. Try to read CLI session metadata let sessionId: string | undefined; let startedAt: number | undefined; if (match.cliId === 'claude-code') { - const meta = readClaudeSessionMeta(match.pid); + const meta = readClaudeSessionMeta(cliPid); if (meta) { sessionId = meta.sessionId; startedAt = meta.startedAt; } } else if (match.cliId === 'codex') { - // Codex has no per-pid state file — bind via the open rollout fd in - // /proc. Worker-side has the same probe as a fallback so this is - // best-effort: we resolve here so the daemon-side adopt UI shows - // an accurate "currently in session X" hint. - const rollout = findCodexRolloutByPid(match.pid); + const rollout = findCodexRolloutByPid(cliPid); if (rollout) sessionId = rollout.cliSessionId; } else if (match.cliId === 'coco') { - // CoCo: probe /proc//fd for an open file under the session dir - // (session.log / traces.jsonl). events.jsonl itself is opened-written- - // closed per event so it's not reliable on its own. Worker-side - // re-probes too, so undefined here is acceptable. - const cocoSession = findCocoSessionByPid(match.pid); + const cocoSession = findCocoSessionByPid(cliPid); if (cocoSession) sessionId = cocoSession.sessionId; } else if (match.cliId === 'traex') { - // TRAE: same open-rollout-fd probe as Codex, with a TRAE-specific - // path matcher (~/.trae/cli/sessions/...). Worker-side re-probes by - // pid as a fallback, so undefined here is acceptable. - const rollout = findTraexRolloutByPid(match.pid); + const rollout = findTraexRolloutByPid(cliPid); if (rollout) sessionId = rollout.cliSessionId; } - // 5b. Fall back to the CLI process's own start time for uptime. Without - // this only Claude (which has a session JSON with startedAt) shows a real - // uptime; every other CLI — cursor/codex/coco/gemini… — rendered "未知". + // 5b. Fall back to the CLI process's own start time for uptime. if (startedAt === undefined) { - startedAt = readProcessStartTime(match.pid); + startedAt = readProcessStartTime(cliPid); } // 6. Get pane dimensions @@ -709,7 +714,7 @@ export function discoverAdoptableSessions(filterCliId?: CliId): AdoptableSession source: 'tmux', tmuxTarget, panePid, - cliPid: match.pid, + cliPid, cliId: match.cliId, sessionId, cwd, @@ -733,7 +738,7 @@ export function discoverAdoptableSessions(filterCliId?: CliId): AdoptableSession * 'cursor' (see cliIdForComm); without the same filter here, discovery surfaces * the session but validation re-identifies nothing and wrongly reports it exited. */ -export function validateTmuxAdoptTarget(tmuxTarget: string, expectedPid: number, filterCliId?: CliId): boolean { +export function validateTmuxAdoptTarget(tmuxTarget: string, expectedPid?: number, filterCliId?: CliId): boolean { // Verify the tmux pane still exists and get its shell PID let panePid: number; try { @@ -747,9 +752,17 @@ export function validateTmuxAdoptTarget(tmuxTarget: string, expectedPid: number, return false; } - // Search the process tree for the expected CLI PID + // Search the process tree for a matching CLI process. Existence check, not + // strict PID equality — the stored PID may be a wrapper (ttadk/aiden) while + // BFS now finds the real CLI child (or vice versa), and CLI PID rotation + // (/clear, /resume) also produces legitimate PID changes. As long as a + // matching CLI process exists in the pane the adopt session is still alive. const match = findCliProcess(panePid, 3, filterCliId); - return match !== undefined && match.pid === expectedPid; + if (!match) return false; + if (expectedPid && match.pid !== expectedPid) { + logger.info(`[adopt] PID mismatch in ${tmuxTarget}: expected=${expectedPid} found=${match.pid} — accepting (CLI may have rotated or wrapper resolved)`); + } + return true; } @@ -768,10 +781,10 @@ export function validateAdoptTarget(target: AdoptableSession | NonNullable): AdoptValidationResult { if (target.source === 'herdr') return validateHerdrAdoptTarget(target.herdrSessionName, target.herdrPaneId ?? target.herdrTarget); + if (!target.tmuxTarget) return 'missing'; const pid = 'originalCliPid' in target ? target.originalCliPid : ('cliPid' in target ? target.cliPid : undefined); - if (!target.tmuxTarget || !pid) return 'missing'; return validateTmuxAdoptTarget(target.tmuxTarget, pid, target.cliId) ? 'alive' : 'missing'; } diff --git a/src/core/types.ts b/src/core/types.ts index 618c3a72d..bbec0d07d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -129,8 +129,9 @@ export interface DaemonSession { /** 文档评论入口(/subscribe-lark-doc):本会话「来自文档评论的轮」的回复落点 * 映射。key = turnId(= 触发评论的 reply_id/comment_id,随消息传给 worker 再 * 随 final_output 传回);value = 该回哪个文档的哪条评论。deliverFinalOutput - * 命中后把正文发表为文档评论而非飞书卡片,并删除该项。仅内存(轮是瞬时的)。 */ - docCommentTurns?: Map; + * 命中后标记 delivered=true(不删除),防止同一 turnId 的重复 final_output + * 重复发文档评论。cardDelivered 防止重复发飞书卡片。仅内存(轮是瞬时的)。 */ + docCommentTurns?: Map; /** Last assistant uuid emitted via the adopt bridge final_output pipeline. * Used by the daemon to dedupe successive `final_output` IPCs (e.g. when * the worker re-drains the transcript after a noisy idle). */ diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 72d79f088..1ab176947 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -27,6 +27,7 @@ import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js'; import { findUniqueClaudeSessionByCwd } from './session-discovery.js'; import { buildMarkdownCard, buildContextualReplyCard } from '../im/lark/md-card.js'; import { replyToDocComment, chunkCommentText, unsubscribeDocFile } from '../im/lark/doc-comment.js'; +import { stripMarkdown } from '../utils/markdown.js'; import { listDocSubscriptionsForSession, removeDocSubscription } from '../services/doc-subs-store.js'; import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; @@ -2388,15 +2389,51 @@ function deliverFinalOutput( // 发表为文档评论(而非飞书卡片),状态卡/占位卡仍留在飞书会话起点。 const docTurn = ds.docCommentTurns?.get(msg.turnId); if (docTurn) { - // 嵌套回复到用户那条评论 thread(已挂在其下,无需再 ↪ 前缀)。这是兜底路径 - // (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。 - const chunks = chunkCommentText(msg.content); - for (let i = 0; i < chunks.length; i++) { - await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined); + // 已投递过的 doc-comment 轮次:跳过文档评论(已发),但仍需继续走 + // 飞书卡片路径——用户期望文档评论 + 飞书会话双通道收到回复。 + if (!docTurn.delivered) { + // botmux send 子进程发完评论后会写 marker(messageId = "doc:"), + // 抑制此兜底路径重复发评论。漏记 marker 则容忍多一条(不丢回复总比丢好)。 + let sendAlreadyPosted = false; + try { + const markerPath = join(config.session.dataDir, 'turn-sends', `${ds.session.sessionId}.jsonl`); + if (existsSync(markerPath)) { + const lines = readFileSync(markerPath, 'utf-8').split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const m = JSON.parse(line); + if (m.messageId === `doc:${docTurn.commentId}`) { sendAlreadyPosted = true; break; } + } catch { /* skip */ } + } + } + } catch { /* best-effort */ } + if (sendAlreadyPosted) { + docTurn.delivered = true; + logger.info(`[${t}] doc-comment final_output skipped — botmux send already posted (turn ${msg.turnId.substring(0, 8)})`); + } else { + // 嵌套回复到用户那条评论 thread(已挂在其下,无需再 ↪ 前缀)。这是兜底路径 + // (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。 + const cleaned = stripMarkdown(msg.content.trim()) || '(无回复内容)'; + const chunks = chunkCommentText(cleaned); + for (let i = 0; i < chunks.length; i++) { + await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined, { isWhole: docTurn.isWhole }); + } + docTurn.delivered = true; + logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`); + } + } else { + logger.info(`[${t}] doc-comment already delivered, skipping doc comment (turn ${msg.turnId.substring(0, 8)})`); } - ds.docCommentTurns?.delete(msg.turnId); + // 继续走飞书卡片路径——文档评论 + 飞书会话双通道投递 + } + + // For doc-comment turns: skip card if already delivered to prevent + // duplicate cards from a second final_output (different lastUuid). + const docTurnForCard = ds.docCommentTurns?.get(msg.turnId); + if (docTurnForCard?.cardDelivered) { ds.lastBridgeEmittedUuid = msg.lastUuid; - logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`); + logger.info(`[${t}] Bridge final_output card skipped — doc-comment card already delivered (turn ${msg.turnId.substring(0, 8)})`); return; } @@ -2431,6 +2468,9 @@ function deliverFinalOutput( // used to swallow the answer; a brand-new message always pings. await scopedReply(cardJson, 'interactive', msg.turnId); ds.lastBridgeEmittedUuid = msg.lastUuid; + // Mark card delivered for doc-comment turns to prevent duplicate cards. + const docTurnCardMark = ds.docCommentTurns?.get(msg.turnId); + if (docTurnCardMark) docTurnCardMark.cardDelivered = true; logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`); } catch (err: any) { if (err instanceof MessageWithdrawnError) { @@ -2463,7 +2503,7 @@ export const __testOnly_finishTurnReactions = finishTurnReactions; // ─── Fork adopt worker ────────────────────────────────────────────────────── -export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata?: boolean }): void { +export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata?: boolean; prompt?: string; turnId?: string }): void { const cb = requireCallbacks(); const workerPath = join(__dirname, '..', 'worker.js'); const t = tag(ds); @@ -2541,15 +2581,19 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata // from there (cursor-agent never calls `botmux send`). // Other CLIs fall back to legacy screen-capture only. const adoptedCliId = adopted.cliId ?? 'claude-code'; - if (adopted.source === 'herdr' && adoptedCliId === 'claude-code' && !adopted.sessionId) { + // For claude-code adopt with no sessionId, try to resolve by cwd. + // tmux discovery may record the wrapper pid (e.g. ttadk) rather than the + // real claude process, so readClaudeSessionMeta(pid) can miss the session. + // The cwd-based fallback is the same strategy used for herdr adopt below. + if (adoptedCliId === 'claude-code' && !adopted.sessionId) { const claudeMeta = findUniqueClaudeSessionByCwd(adopted.cwd); if (claudeMeta?.sessionId) { adopted.sessionId = claudeMeta.sessionId; if (ds.session.adoptedFrom) ds.session.adoptedFrom.sessionId = claudeMeta.sessionId; sessionStore.updateSession(ds.session); - logger.info(`[${t}] Resolved Claude session for adopted herdr target by cwd`); + logger.info(`[${t}] Resolved Claude session for adopted ${adopted.source} target by cwd`); } else { - logger.warn(`[${t}] Cannot resolve unique Claude session for adopted herdr target; final replies may be unavailable`); + logger.warn(`[${t}] Cannot resolve unique Claude session for adopted ${adopted.source} target; final replies may be unavailable`); } } const hasCliPid = typeof adopted.originalCliPid === 'number'; @@ -2574,7 +2618,9 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata cliSessionId: isStructuredBridge ? adopted.sessionId : undefined, model: botCfg.model, disableCliBypass: botCfg.disableCliBypass === true, - prompt: '', + prompt: opts?.prompt ?? '', + turnId: opts?.turnId ?? undefined, + docComment: !!ds.docCommentTurns?.has(opts?.turnId ?? ''), resume: false, ownerOpenId: ds.ownerOpenId, webPort: ds.session.webPort, diff --git a/src/daemon.ts b/src/daemon.ts index e32c745e0..9e4aa9063 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -50,6 +50,7 @@ import { initWorkerPool, setActiveSessionsRegistry, forkWorker, + forkAdoptWorker, killWorker, reapOrphanWorkers, scheduleCardPatch, @@ -110,7 +111,7 @@ import { EventLog as WorkflowEventLog } from './workflows/events/append.js'; import { replay as replayWorkflow } from './workflows/events/replay.js'; import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, evaluateTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, type RoutingContext, type TalkEvaluation, type DocCommentContext } from './im/lark/event-dispatcher.js'; import { listAllDocSubscriptions, listDocSubscriptionsForSession, removeDocSubscription } from './services/doc-subs-store.js'; -import { subscribeDocFile, unsubscribeDocFile } from './im/lark/doc-comment.js'; +import { subscribeDocFile, unsubscribeDocFile, buildDocCommentPrompt } from './im/lark/doc-comment.js'; import { learnFromMentions, resolveSender, flushIdentityCacheSync } from './im/lark/identity-cache.js'; import { normalizeBrand } from './im/lark/lark-hosts.js'; import { renderBufferedSenderBlock } from './core/session-manager.js'; @@ -3338,7 +3339,11 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise rememberLastCliInput(ds, promptContent, wrappedPrompt); await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId); sessionStore.updateSession(ds.session); - forkWorker(ds, wrappedPrompt, ds.hasHistory); + if (ds.adoptedFrom) { + forkAdoptWorker(ds, { prompt: wrappedPrompt, turnId: parsed.messageId }); + } else { + forkWorker(ds, wrappedPrompt, ds.hasHistory); + } } } @@ -3366,7 +3371,14 @@ async function handleDocComment(ctx: DocCommentContext): Promise { const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined; const authorName = sender?.name || ctx.authorOpenId?.slice(0, 8) || '?'; - const promptContent = `${tr('daemon.doc_comment_prefix', { author: authorName }, loc)}\n${text}`; + const promptContent = buildDocCommentPrompt({ + fileToken: sub.fileToken, + fileType: sub.fileType, + question: text, + quote: ctx.quote, + isWhole: ctx.isWhole, + brand: normalizeBrand(getBot(larkAppId).config.brand), + }); // 记录本轮回评论的落点。两条路都要覆盖: // • ds.docCommentTurns(内存,按 turnId)→ deliverFinalOutput「兜底」分流用 @@ -3378,8 +3390,9 @@ async function handleDocComment(ctx: DocCommentContext): Promise { commentId, replyToOpenId: ctx.authorOpenId, replyToName: sender?.name, + isWhole: ctx.isWhole, }); - const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId }; + const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId, isWhole: ctx.isWhole }; const dsBotCfg = getBot(ds.larkAppId).config; const selfBot = getBot(ds.larkAppId); @@ -3405,7 +3418,7 @@ async function handleDocComment(ctx: DocCommentContext): Promise { rememberLastCliInput(ds, promptContent, msgContent); sessionStore.updateSession(ds.session); // 先落盘,botmux send 子进程才读得到落点 await noteTurnReceived(ds, commentId, text, sender, turnId); - ds.worker.send({ type: 'message', content: msgContent, turnId } as DaemonToWorker); + ds.worker.send({ type: 'message', content: msgContent, turnId, docComment: true } as DaemonToWorker); logger.info(`[${tag(ds)}] doc-comment turn injected (turn ${turnId.slice(0, 8)})`); } else { // Worker 挂起 / 已退出 —— resume 重 fork(与 handleThreadReply 同路)。 @@ -3432,7 +3445,11 @@ async function handleDocComment(ctx: DocCommentContext): Promise { rememberLastCliInput(ds, promptContent, wrappedPrompt); await noteTurnReceived(ds, commentId, text, sender, turnId); sessionStore.updateSession(ds.session); - forkWorker(ds, wrappedPrompt, ds.hasHistory); + if (ds.adoptedFrom) { + forkAdoptWorker(ds, { prompt: wrappedPrompt, turnId }); + } else { + forkWorker(ds, wrappedPrompt, ds.hasHistory); + } } } diff --git a/src/im/lark/doc-comment.ts b/src/im/lark/doc-comment.ts index 9c8439e22..9470595a4 100644 --- a/src/im/lark/doc-comment.ts +++ b/src/im/lark/doc-comment.ts @@ -59,6 +59,10 @@ export interface DocComment { commentId: string; /** 评论是否已解决。 */ isSolved: boolean; + /** 是否全文评论(针对整篇文档,vs 行内/锚定评论针对选中文字)。 */ + isWhole: boolean; + /** 行内评论选中的原文(全文评论无此字段)。 */ + quote?: string; /** 该评论 thread 下所有回复(飞书把评论建模成 reply_list)。 */ replies: Array<{ replyId: string; @@ -159,6 +163,10 @@ function buildQuery(params?: DriveCallOpts['params']): string { return q ? `?${q}` : ''; } +/** drive/wiki API 调用的超时(ms)。裸 fetch / SDK request 均无内置超时, + * 网络异常时 Promise 会永久 pending → processCommentEvent 整条链路静默卡死。 */ +const DRIVE_API_TIMEOUT_MS = 15_000; + /** * 调一个 drive/wiki OpenAPI。优先 user token(裸 fetch),拿不到 token 或遇 * 401/403 时回退 tenant(SDK client.request 自带 tenant_access_token + GET @@ -171,12 +179,19 @@ async function driveApiCall(larkAppId: string, opts: DriveCallOpts): Promise { const c = getBotClient(larkAppId); - return c.request({ - method: opts.method, - url: opts.path, - params: opts.params, - ...(opts.data !== undefined ? { data: opts.data } : {}), - }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DRIVE_API_TIMEOUT_MS); + try { + return await c.request({ + method: opts.method, + url: opts.path, + params: opts.params, + ...(opts.data !== undefined ? { data: opts.data } : {}), + signal: controller.signal, + } as any); + } finally { + clearTimeout(timer); + } }; const callUser = async () => { const userToken = await resolveUserToken(bot.config.larkAppId, bot.config.larkAppSecret, brand); @@ -190,12 +205,17 @@ async function driveApiCall(larkAppId: string, opts: DriveCallOpts): Promise { const url = `${larkHosts(brand).openApi}${opts.path}${buildQuery(opts.params)}`; - const res = await fetch(url, { - method: opts.method, - headers: { - Authorization: `Bearer ${userToken}`, - ...(opts.data !== undefined ? { 'Content-Type': 'application/json' } : {}), - }, - ...(opts.data !== undefined ? { body: JSON.stringify(opts.data) } : {}), - }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DRIVE_API_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { + method: opts.method, + headers: { + Authorization: `Bearer ${userToken}`, + ...(opts.data !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + ...(opts.data !== undefined ? { body: JSON.stringify(opts.data) } : {}), + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) { + throw new Error(`drive API ${opts.path} 超时 (${DRIVE_API_TIMEOUT_MS}ms)`); + } + throw err; + } finally { + clearTimeout(timer); + } if (res.status === 401) { throw new UserTokenMissingError('User Token 已失效(HTTP 401)。请在话题中 /login 重新授权。'); } @@ -316,9 +349,13 @@ export async function getDocComment( function normalizeComment(raw: any): DocComment { const replies = Array.isArray(raw?.reply_list?.replies) ? raw.reply_list.replies : []; + // 飞书 batch_query 响应里 quote 在 raw.extra.quote 或 raw.quote + const quoteText = raw?.extra?.quote ?? raw?.quote; return { commentId: raw?.comment_id ?? '', isSolved: raw?.is_solved === true, + isWhole: raw?.is_whole === true, + quote: typeof quoteText === 'string' ? quoteText : undefined, replies: replies.map((r: any) => ({ replyId: r?.reply_id ?? '', userId: r?.user_id, @@ -345,7 +382,14 @@ export async function replyToDocComment( commentId: string, text: string, mentionOpenId?: string, + opts?: { isWhole?: boolean }, ): Promise<{ replyId?: string; commentId?: string }> { + // 全文评论不允许嵌套回复,直接走新建全文评论(跳过注定失败的 probe)。 + if (opts?.isWhole) { + logger.info(`[doc-comment] skip in-thread reply (isWhole=true), posting top-level comment`); + const c = await createDocComment(larkAppId, file, text, mentionOpenId); + return { replyId: c.replyId, commentId: c.commentId }; + } const elements = buildCommentElements(text, mentionOpenId); let res: any; try { @@ -442,3 +486,66 @@ export function chunkCommentText(text: string, max = DOC_COMMENT_MAX_CHARS): str if (rest) chunks.push(rest); return chunks; } + +// ─── Prompt 构建 ───────────────────────────────────────────────────────────────── + +/** + * 为文档评论场景构造结构化 prompt,替代简单前缀。 + * + * 让 AI 知道自己在一个文档评论场景中:提供文档链接、类型、评论范围、 + * 引用原文、读取指令(让 AI 自己用 lark-cli 读文档正文)、纯文本回复要求。 + * + * 参考 lark-coding-agent-bridge 的 buildCommentPrompt,适配 botmux 的投递方式。 + */ +export function buildDocCommentPrompt(opts: { + fileToken: string; + fileType: string; + question: string; + quote?: string; + isWhole?: boolean; + brand?: Brand; +}): string { + const { fileToken, fileType, question, quote, isWhole, brand } = opts; + const host = larkHosts(brand ?? 'feishu'); + // 飞书文档链接用的是 openApi 域名去掉 /open-apis 前缀,如 https://open.feishu.cn → https://feishu.cn + const webBase = host.openApi.replace(/\/open-apis$/, ''); + const docUrl = `${webBase}/${fileType}/${fileToken}`; + const parts: string[] = []; + parts.push('我在飞书云文档里被 @了。文档信息:'); + parts.push(`- 链接:${docUrl}`); + parts.push(`- file_token:${fileToken}`); + parts.push(`- 类型:${fileType}`); + parts.push(`- 评论范围:${isWhole ? '全文评论(针对整篇)' : '行内评论(针对选中文字)'}`); + if (quote) { + parts.push(''); + parts.push(`用户选中的原文:\n> ${quote.replace(/\n/g, '\n> ')}`); + } + parts.push(''); + parts.push(`用户的问题:${question}`); + parts.push(''); + parts.push(docReadInstruction(fileToken, fileType)); + parts.push(''); + parts.push( + '评论回复由 botmux 负责:不要调用云文档评论或回复接口,也不要给评论添加或删除 reaction;最终答案直接用纯文本交给 botmux。', + ); + parts.push(''); + parts.push( + '回复要求:直接用纯文本,不要 markdown(不要 ** __ # - * > ` 之类的标记),不要代码块;不要输出内部思考、内部分析、读取步骤、工具调用过程或工具日志。若用户要求解释依据,只说明用户可见的依据和结论。云文档评论框不渲染 markdown,会原样显示这些符号。', + ); + return parts.join('\n'); +} + +/** 按 fileType 给出文档读取命令建议。 */ +function docReadInstruction(fileToken: string, fileType: string): string { + if (fileType === 'doc' || fileType === 'docx') { + return ( + '读取文档内容:优先使用当前 docs v2 读取命令:\n' + + ` \`lark-cli docs +fetch --api-version v2 --doc ${fileToken} --doc-format markdown\`\n` + + '如果本机 lark-cli 不支持上述参数,不要在同一错误上反复重试;使用当前可用的等价读取命令读取同一 file_token。' + ); + } + if (fileType === 'sheet') { + return '读取表格内容:这是 sheet 类型,不要使用 docs +fetch。请按当前可用的表格读取工具或本机 lark-cli 支持的表格读取命令读取同一 file_token;如果命令参数不兼容,不要在同一错误上反复重试。'; + } + return '读取文件内容:这是 file 类型,不要使用 docs +fetch。请按当前可用的云空间文件工具或本机 lark-cli 支持的文件读取/下载命令处理同一 file_token;如果命令参数不兼容,不要在同一错误上反复重试。'; +} diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index da2d44fd4..cd83484e5 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -991,6 +991,10 @@ export interface DocCommentContext { text: string; /** 评论发表者 open_id。 */ authorOpenId?: string; + /** 行内评论选中的原文(全文评论无此字段)。 */ + quote?: string; + /** 是否全文评论(true = 针对整篇文档,false = 针对选中文字)。 */ + isWhole?: boolean; } /** @@ -1258,7 +1262,17 @@ function handleCommentEventAckSafe(data: any, larkAppId: string, handlers: Event const eventKey = `drive.comment_add:${larkAppId}:${eventIdForKey(data) ?? `${parsed.fileToken ?? '?'}:${parsed.replyId ?? parsed.commentId ?? '?'}`}`; scheduleAckSafeEvent(eventKey, async () => { try { - await processCommentEvent(parsed, larkAppId, handlers); + // processCommentEvent 涉及多次网络调用(token 刷新、getDocComment、 + // replyToDocComment),任一环节都可能卡住导致 WS 事件处理阻塞。 + // 加整体超时兜底,确保评论事件不会永久挂起。 + const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 30_000)); + const result = await Promise.race([ + processCommentEvent(parsed, larkAppId, handlers).then(() => 'done' as const), + timeout, + ]); + if (result === 'timeout') { + logger.warn(`[doc-comment] processCommentEvent timed out (30s) file=${parsed.fileToken?.slice(0, 12)} comment=${parsed.commentId?.slice(0, 12)}`); + } } catch (err) { logger.error(`Error handling doc-comment event: ${err}`); } @@ -1286,7 +1300,13 @@ async function processCommentEvent( // 2) 拉评论 thread 取权威正文 / 作者 / @ 列表(事件 payload 不保证带全), // 同时用最新一条回复作为"触发回复"。 - const comment = await getDocComment(larkAppId, { fileToken, fileType: sub.fileType }, commentId); + let comment: Awaited>; + try { + comment = await getDocComment(larkAppId, { fileToken, fileType: sub.fileType }, commentId); + } catch (err) { + logger.warn(`[doc-comment] getDocComment failed: ${err instanceof Error ? err.message : err} (file=${fileToken.slice(0, 12)} comment=${commentId.slice(0, 12)})`); + return; + } if (!comment || comment.replies.length === 0) { logger.info(`[doc-comment] event dropped: 取不到评论内容 comment=${commentId.slice(0, 12)}(replies=${comment ? comment.replies.length : 'null'})`); return; @@ -1322,6 +1342,8 @@ async function processCommentEvent( replyId: trigger.replyId || commentId, text, authorOpenId: trigger.userId, + quote: comment?.quote, + isWhole: comment?.isWhole, }); } diff --git a/src/types.ts b/src/types.ts index 10229409e..369c91fd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,7 +79,7 @@ export interface Session { * send 跑在独立 CLI 子进程、只能从磁盘读会话态,故把当前轮的回评论落点持久化 * 在这里。每开新轮重置(beginNewTurn 清空;handleDocComment 设值)。 */ - currentDocCommentTarget?: { fileToken: string; fileType: string; commentId: string; replyToName?: string; replyToOpenId?: string; turnId: string }; + currentDocCommentTarget?: { fileToken: string; fileType: string; commentId: string; replyToName?: string; replyToOpenId?: string; turnId: string; isWhole?: boolean }; /** open_id of the quote-target message's sender — used by --mention-back. */ quoteTargetSenderOpenId?: string; /** Whether the quote-target sender is a bot (vs a human) — drives the @@ -262,8 +262,8 @@ export type TermActionKey = /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } - | { type: 'message'; content: string; turnId?: string } + | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; docComment?: boolean; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'message'; content: string; turnId?: string; docComment?: boolean } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate * IPCs would race: process.on('message') handlers don't serialize, and the diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 000000000..9c2438683 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,27 @@ +/** + * Strip the most common markdown markers so a plain-text comment doesn't + * show literal `**` / `#` / `> ` etc. Conservative — only touches bold, + * italic, headings, blockquote, list bullets, and inline code. + * + * Ported from lark-coding-agent-bridge/src/bot/comments.ts stripMarkdown. + */ +export function stripMarkdown(s: string): string { + return s + // headings: "# foo" -> "foo" + .replace(/^#{1,6}\s+/gm, '') + // bold: **foo** / __foo__ + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + // italic: *foo* / _foo_ (avoid matching inside words) + .replace(/(? foo" + .replace(/^>\s?/gm, '') + // remove fenced code-block backticks but keep contents + .replace(/```[a-zA-Z]*\n?/g, '') + .replace(/```/g, ''); +} diff --git a/src/worker.ts b/src/worker.ts index 831063ac8..3d0bb1302 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -304,6 +304,9 @@ const inflightInputs = new InflightInputTracker(); * start work before their history/transcript submit marker is observable. */ let lastPtyActivityAtMs = 0; let currentBotmuxTurnId: string | undefined; +/** Turn IDs originating from doc-comment events — emitReadyTurns uses + * trailingAssistantText (final answer only) instead of the full join. */ +const docCommentTurnIds = new Set(); function writeCliPidMarker(): void { if (!cliPidMarker || !sessionId) return; try { @@ -1546,7 +1549,11 @@ function emitReadyTurns(): void { // material-longer gate, re-posting turns the model already `botmux send`ed. // Adopt keeps the full join: transcript drain is that mode's only channel, // so interim narration is the user's only window into the turn. - const assistantText = adoptMode ? joinAssistantText(matched) : trailingAssistantText(drained.events, turn.assistantUuids); + // Doc-comment turns always use trailingAssistantText — the document comment + // box shows plain text only, so intermediate steps (tool calls, narration) + // would render as noise. + const isDocComment = docCommentTurnIds.has(turn.turnId); + const assistantText = (adoptMode && !isDocComment) ? joinAssistantText(matched) : trailingAssistantText(drained.events, turn.assistantUuids); if (assistantText.length === 0) continue; const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1]; @@ -3952,7 +3959,7 @@ function spawnCli(cfg: Extract): void { // merged into childEnv) so the tmux/zellij backends inject it via the per-pane // `/usr/bin/env` prefix and never into the shared backing-server global env, // keeping it from leaking across bots. Re-sanitized here (crossed IPC). - const perBotInjectEnv = sanitizePerBotEnv(cfg.env); + let perBotInjectEnv = sanitizePerBotEnv(cfg.env); const perBotInjectKeys = Object.keys(perBotInjectEnv); if (perBotInjectKeys.length) log(`Injecting ${perBotInjectKeys.length} per-bot env var(s): ${perBotInjectKeys.join(', ')}`); @@ -4060,6 +4067,63 @@ function spawnCli(cfg: Extract): void { // keeps the behaviour intentional rather than ambient. (Codex review note.) delete (childEnv as Record).CJADK_INTERACTIVE; + // ttadk gateway env routing for persistent backends (tmux/zellij). + // + // Problem: tmux/zellij panes inherit from the backing SERVER's global env, + // not from the client env passed to pty.spawn(). The daemon's ANTHROPIC_* + // vars are NOT in the tmux server's global env (it was started before these + // vars were set), so panes never see them. Without ANTHROPIC_BASE_URL, the + // CLI falls back to api.anthropic.com and uses the user's own Anthropic + // account — bypassing ttadk's gateway entirely. + // + // Fix: for ttadk-wrapped sessions, we move the gateway-needed ANTHROPIC_* + // vars (BASE_URL, AUTH_TOKEN, API_KEY, CUSTOM_HEADERS) from childEnv into + // injectEnv so the persistent backends inject them via the per-pane + // `/usr/bin/env KEY=VAL` prefix. We also delete model-override vars + // (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL) that would conflict with + // ttadk's own -m / CLAUDE_CODE_SUBAGENT_MODEL routing, and add + // them to unsetEnvKeys so the shell wrapper unsets them from the pane + // (in case the tmux server has them from a stale global env). + const TTADK_GATEWAY_ANTHROPIC_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_CUSTOM_HEADERS', + ]); + const TTADK_REMOVED_ANTHROPIC_KEYS = new Set([ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + ]); + let unsetEnvKeys: string[] | undefined; + if (ttadkGateway) { + unsetEnvKeys = []; + const gatewayInject: Record = {}; + for (const key of Object.keys(childEnv)) { + if (TTADK_GATEWAY_ANTHROPIC_KEYS.has(key)) { + // Move gateway-needed vars from childEnv → injectEnv so persistent + // backends inject them via /usr/bin/env in the pane. + gatewayInject[key] = childEnv[key]!; + delete (childEnv as Record)[key]; + } else if (TTADK_REMOVED_ANTHROPIC_KEYS.has(key)) { + // Delete model-override vars and add to tmux unset clause. + unsetEnvKeys.push(key); + delete (childEnv as Record)[key]; + } + } + if (Object.keys(gatewayInject).length) { + // Merge into perBotInjectEnv (already sanitized). These are appended + // AFTER the per-bot keys in the /usr/bin/env prefix so they win over + // any same-named leftover in the pane's inherited env. + Object.assign(perBotInjectEnv, gatewayInject); + log(`ttadk launcher: moved ${Object.keys(gatewayInject).length} ANTHROPIC_* gateway var(s) to injectEnv: ${Object.keys(gatewayInject).join(', ')}`); + } + if (unsetEnvKeys.length) { + log(`ttadk launcher: stripped ${unsetEnvKeys.length} ANTHROPIC_* model-override var(s): ${unsetEnvKeys.join(', ')}`); + } + } + if (cfg.wrapperCli && cfg.wrapperCli.trim()) { if (sandboxOn) { log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with bwrap)`); @@ -4096,12 +4160,15 @@ function spawnCli(cfg: Extract): void { } } + const finalInjectEnv = Object.keys(perBotInjectEnv).length ? perBotInjectEnv : undefined; + if (finalInjectEnv) log(`injectEnv keys: ${Object.keys(finalInjectEnv).join(', ')}`); backend.spawn(spawnBin, spawnArgs, { cwd: spawnCwd, cols: PTY_COLS, rows: PTY_ROWS, env: childEnv as Record, - injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined, + injectEnv: finalInjectEnv, + unsetEnvKeys, }); // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.) @@ -4963,6 +5030,7 @@ process.on('message', async (raw: unknown) => { try { if (msg.turnId) { currentBotmuxTurnId = msg.turnId; + if (msg.docComment) docCommentTurnIds.add(msg.turnId); writeCliPidMarker(); } let port = 0; @@ -5012,6 +5080,7 @@ process.on('message', async (raw: unknown) => { if (tmuxScrolledHalfPages > 0) exitTmuxScrollMode(); const content = msg.content; currentBotmuxTurnId = msg.turnId; + if (msg.docComment && msg.turnId) docCommentTurnIds.add(msg.turnId); writeCliPidMarker(); if (lastInitConfig?.adoptMode) { // Bridge mode: capture transcript baseline BEFORE writing to the pane, @@ -5068,6 +5137,7 @@ process.on('message', async (raw: unknown) => { log(`Codex adopt writeInput error: ${err.message}`); } } else if ('sendText' in backend && 'sendSpecialKeys' in backend) { + log(`[adopt] sendText to pane: "${content.substring(0, 80)}"`); (backend as any).sendText(content); // Beat between text and Enter so the adopted CLI's input layer // has time to register the typed chars before submit. Without @@ -5078,12 +5148,15 @@ process.on('message', async (raw: unknown) => { // that fresh-spawn mode goes through and matches the slash- // command (raw_input) fix. await new Promise(r => setTimeout(r, 200)); + log('[adopt] sendSpecialKeys Enter'); (backend as any).sendSpecialKeys('Enter'); } else { backend.write(content + '\r'); } isPromptReady = false; idleDetector?.reset(); + } else { + log(`[adopt] backend is null — message dropped: "${content.substring(0, 80)}"`); } } else { // Non-adopt: enqueue only. Bridge mark is deferred to flushPending