Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions src/adapters/backend/tmux-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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.
*
Expand Down Expand Up @@ -269,8 +279,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 ` +
Expand Down Expand Up @@ -626,19 +636,24 @@ export function buildBotmuxEnvAssignments(
}

/**
* Default wrapper script for `<shell> -c`. Sees argv as:
* Build the shell wrapper script for `<shell> -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();

export const DIAGNOSTIC_SHELL_SCRIPT = [
'cd -- "$1" 2>/dev/null || cd "$HOME" 2>/dev/null || cd /',
Expand All @@ -663,13 +678,13 @@ export const DIAGNOSTIC_SHELL_SCRIPT = [
* 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`,
Expand Down
14 changes: 14 additions & 0 deletions src/adapters/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export interface SpawnOpts {
* merges them into the child env. Already sanitized (see sanitizePerBotEnv).
*/
injectEnv?: Record<string, string>;
/**
* 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 {
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/backend/zellij-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/core/persistent-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sid8> 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;
Expand Down
65 changes: 39 additions & 26 deletions src/core/session-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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/<pid>/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
Expand All @@ -709,7 +714,7 @@ export function discoverAdoptableSessions(filterCliId?: CliId): AdoptableSession
source: 'tmux',
tmuxTarget,
panePid,
cliPid: match.pid,
cliPid,
cliId: match.cliId,
sessionId,
cwd,
Expand All @@ -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 {
Expand All @@ -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;
}


Expand All @@ -768,10 +781,10 @@ export function validateAdoptTarget(target: AdoptableSession | NonNullable<impor

export function validateAdoptTargetState(target: AdoptableSession | NonNullable<import('./types.js').DaemonSession['adoptedFrom']>): 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';
}

Expand Down
5 changes: 3 additions & 2 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ export interface DaemonSession {
/** 文档评论入口(/subscribe-lark-doc):本会话「来自文档评论的轮」的回复落点
* 映射。key = turnId(= 触发评论的 reply_id/comment_id,随消息传给 worker 再
* 随 final_output 传回);value = 该回哪个文档的哪条评论。deliverFinalOutput
* 命中后把正文发表为文档评论而非飞书卡片,并删除该项。仅内存(轮是瞬时的)。 */
docCommentTurns?: Map<string, { fileToken: string; fileType: string; commentId: string; replyToOpenId?: string; replyToName?: string }>;
* 命中后标记 delivered=true(不删除),防止同一 turnId 的重复 final_output
* 重复发文档评论。cardDelivered 防止重复发飞书卡片。仅内存(轮是瞬时的)。 */
docCommentTurns?: Map<string, { fileToken: string; fileType: string; commentId: string; replyToOpenId?: string; replyToName?: string; isWhole?: boolean; delivered?: boolean; cardDelivered?: boolean }>;
/** 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). */
Expand Down
Loading