From 688bb551b2325b0f6f99927e07a58ce6f2d233ff Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 13:19:14 +0800 Subject: [PATCH 01/10] fix(tmux): defer subagent attach until pane focus --- src/features/tmux-subagent/manager.ts | 9 +- .../tmux-subagent/polling-manager.test.ts | 101 +++++++++++++++++- src/features/tmux-subagent/polling-manager.ts | 46 +++++++- .../tmux-subagent/tracked-session-state.ts | 1 + src/features/tmux-subagent/types.ts | 1 + src/shared/tmux/tmux-utils.ts | 2 + src/shared/tmux/tmux-utils/pane-activate.ts | 33 ++++++ .../tmux/tmux-utils/pane-command.test.ts | 32 ++++++ src/shared/tmux/tmux-utils/pane-command.ts | 15 +++ src/shared/tmux/tmux-utils/pane-replace.ts | 11 +- src/shared/tmux/tmux-utils/pane-spawn.ts | 9 +- 11 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/pane-activate.ts create mode 100644 src/shared/tmux/tmux-utils/pane-command.test.ts create mode 100644 src/shared/tmux/tmux-utils/pane-command.ts diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index e97273e70..f07f82c8b 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -12,6 +12,7 @@ import { killTmuxSessionIfExists, getIsolatedSessionName, sweepStaleOmoAgentSessions, + activateTmuxPane, } from "../../shared/tmux" import { queryWindowState as defaultQueryWindowState } from "./pane-state-querier" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" @@ -133,7 +134,9 @@ export class TmuxSessionManager { this.client, this.sessions, this.closeSessionFromPolling.bind(this), - this.retryPendingCloses.bind(this) + this.retryPendingCloses.bind(this), + this.queryWindowStateSafely.bind(this), + this.activateTrackedSessionPane.bind(this), ) this.deps.log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, @@ -337,6 +340,10 @@ export class TmuxSessionManager { } } + private async activateTrackedSessionPane(tracked: TrackedSession): Promise { + return activateTmuxPane(tracked.paneId, tracked.sessionId, this.serverUrl, this.projectDirectory) + } + private windowStateContainsPane(state: WindowState, paneId: string): boolean { return state.mainPane?.paneId === paneId || state.agentPanes.some((pane) => pane.paneId === paneId) diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 7c0d3cd1d..e3ed4e767 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test" import { TmuxPollingManager } from "./polling-manager" -import type { TrackedSession } from "./types" +import type { TrackedSession, WindowState } from "./types" import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("TmuxPollingManager overlap", () => { @@ -11,6 +11,7 @@ describe("TmuxPollingManager overlap", () => { sessionId: "ses-1", paneId: "%1", description: "test", + attachActivated: true, createdAt: new Date(), lastSeenAt: new Date(), closePending: false, @@ -64,6 +65,7 @@ describe("TmuxPollingManager overlap", () => { sessionId: "ses-1", paneId: "%1", description: "test", + attachActivated: true, createdAt: new Date(Date.now() - 15_000), lastSeenAt: new Date(), closePending: false, @@ -117,6 +119,7 @@ describe("TmuxPollingManager overlap", () => { sessionId: "ses-1", paneId: "%1", description: "test", + attachActivated: true, createdAt: new Date(now - 1_000), lastSeenAt: new Date(now - 7_000), closePending: false, @@ -156,6 +159,7 @@ describe("TmuxPollingManager overlap", () => { sessionId: "ses-1", paneId: "%1", description: "test", + attachActivated: true, createdAt: new Date(now - 11 * 60 * 1000), lastSeenAt: new Date(now), closePending: false, @@ -194,6 +198,7 @@ describe("TmuxPollingManager overlap", () => { sessionId: "ses-1", paneId: "%1", description: "test", + attachActivated: true, createdAt: new Date(Date.now() - 15_000), lastSeenAt: new Date(), closePending: false, @@ -237,4 +242,98 @@ describe("TmuxPollingManager overlap", () => { // then expect(closedSessionIds).toEqual([]) }) + + test("activates focused panes once before polling statuses", async () => { + //#given + const sessions = new Map() + const tracked: TrackedSession = { + sessionId: "ses-1", + paneId: "%1", + description: "test", + attachActivated: false, + createdAt: new Date(), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + } + sessions.set("ses-1", tracked) + + const activatedSessionIds: string[] = [] + const client = { + session: { + status: async () => ({ data: { "ses-1": { type: "running" } } }), + messages: async () => ({ data: [] }), + }, + } + const windowState: WindowState = { + windowWidth: 160, + windowHeight: 48, + mainPane: null, + agentPanes: [ + { paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true }, + ], + } + const manager = new TmuxPollingManager( + unsafeTestValue(client), + sessions, + async () => {}, + undefined, + async () => windowState, + async (session) => { + activatedSessionIds.push(session.sessionId) + return true + }, + ) + const pollSessions = unsafeTestValue<{ pollSessions: () => Promise }>(manager).pollSessions + + //#when + await pollSessions.call(manager) + await pollSessions.call(manager) + + //#then + expect(activatedSessionIds).toEqual(["ses-1"]) + expect(tracked.attachActivated).toBe(true) + }) + + test("does not close non-activated panes before focus activation", async () => { + //#given + const sessions = new Map() + sessions.set("ses-1", { + sessionId: "ses-1", + paneId: "%1", + description: "test", + attachActivated: false, + createdAt: new Date(Date.now() - 15_000), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + stableIdlePolls: 3, + observedIdleActivityVersion: 0, + }) + + const closedSessionIds: string[] = [] + const client = { + session: { + status: async () => ({ data: { "ses-1": { type: "idle" } } }), + messages: async () => ({ data: [] }), + }, + } + const manager = new TmuxPollingManager( + unsafeTestValue(client), + sessions, + async (sessionId) => { + closedSessionIds.push(sessionId) + }, + ) + const pollSessions = unsafeTestValue<{ pollSessions: () => Promise }>(manager).pollSessions + + //#when + await pollSessions.call(manager) + + //#then + expect(closedSessionIds).toEqual([]) + expect(sessions.has("ses-1")).toBe(true) + }) }) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index c34e126aa..384ef7c2e 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -4,7 +4,7 @@ import { SESSION_MISSING_GRACE_MS, SESSION_TIMEOUT_MS, } from "../../shared/tmux" -import type { TrackedSession } from "./types" +import type { TrackedSession, WindowState } from "./types" import { log } from "../../shared" import { normalizeSDKResponse } from "../../shared" import { resolveMessageEventSessionID } from "../../shared/event-session-id" @@ -20,7 +20,9 @@ export class TmuxPollingManager { private client: OpencodeClient, private sessions: Map, private closeSessionById: (sessionId: string) => Promise, - private retryPendingCloses?: () => Promise + private retryPendingCloses?: () => Promise, + private getWindowState?: () => Promise, + private activateSessionPane?: (tracked: TrackedSession) => Promise, ) {} handleEvent(event: { type: string; properties?: Record }): void { @@ -60,6 +62,8 @@ export class TmuxPollingManager { return } + await this.activateFocusedPanes() + const statusResult = await this.client.session.status({ path: undefined }) const allStatuses = normalizeSDKResponse(statusResult, {} as Record) @@ -72,6 +76,14 @@ export class TmuxPollingManager { const sessionsToClose: string[] = [] for (const [sessionId, tracked] of this.sessions.entries()) { + if (!tracked.attachActivated) { + log("[tmux-session-manager] skipping close checks for non-activated pane", { + sessionId, + paneId: tracked.paneId, + }) + continue + } + const status = allStatuses[sessionId] const isIdle = status?.type === "idle" @@ -185,4 +197,34 @@ export class TmuxPollingManager { return undefined } + + private async activateFocusedPanes(): Promise { + if (!this.getWindowState || !this.activateSessionPane || this.sessions.size === 0) { + return + } + + const state = await this.getWindowState().catch(() => null) + if (!state) return + + const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable => Boolean(pane)) + const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId)) + if (activePaneIds.size === 0) return + + for (const tracked of this.sessions.values()) { + if (tracked.attachActivated) continue + if (!activePaneIds.has(tracked.paneId)) continue + + const activated = await this.activateSessionPane(tracked) + if (activated) { + tracked.attachActivated = true + tracked.lastSeenAt = new Date() + tracked.stableIdlePolls = 0 + tracked.observedIdleActivityVersion = tracked.activityVersion + log("[tmux-session-manager] activated focused pane", { + sessionId: tracked.sessionId, + paneId: tracked.paneId, + }) + } + } + } } diff --git a/src/features/tmux-subagent/tracked-session-state.ts b/src/features/tmux-subagent/tracked-session-state.ts index 9bcf94674..9def399e9 100644 --- a/src/features/tmux-subagent/tracked-session-state.ts +++ b/src/features/tmux-subagent/tracked-session-state.ts @@ -12,6 +12,7 @@ export function createTrackedSession(params: { sessionId: params.sessionId, paneId: params.paneId, description: params.description, + attachActivated: false, createdAt: now, lastSeenAt: now, closePending: false, diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index db8f88d69..36cc4939d 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -2,6 +2,7 @@ export interface TrackedSession { sessionId: string paneId: string description: string + attachActivated: boolean createdAt: Date lastSeenAt: Date closePending: boolean diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index 58f033bc9..d62025dfb 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -9,9 +9,11 @@ export type { PaneDimensions } from "./tmux-utils/pane-dimensions" export { spawnTmuxPane } from "./tmux-utils/pane-spawn" export { closeTmuxPane } from "./tmux-utils/pane-close" export { replaceTmuxPane } from "./tmux-utils/pane-replace" +export { activateTmuxPane } from "./tmux-utils/pane-activate" export { spawnTmuxWindow } from "./tmux-utils/window-spawn" export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn" export { killTmuxSessionIfExists } from "./tmux-utils/session-kill" export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep" +export { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./tmux-utils/pane-command" export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout" diff --git a/src/shared/tmux/tmux-utils/pane-activate.ts b/src/shared/tmux/tmux-utils/pane-activate.ts new file mode 100644 index 000000000..cb2e78108 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-activate.ts @@ -0,0 +1,33 @@ +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import { log } from "../../logger" +import { runTmuxCommand } from "../runner" +import { isInsideTmux } from "./environment" +import { buildTmuxAttachCommand } from "./pane-command" + +export async function activateTmuxPane( + paneId: string, + sessionId: string, + serverUrl: string, + directory: string, +): Promise { + if (!isInsideTmux()) { + log("[activateTmuxPane] SKIP: not inside tmux", { paneId, sessionId }) + return false + } + + const tmux = await getTmuxPath() + if (!tmux) { + log("[activateTmuxPane] SKIP: tmux not found", { paneId, sessionId }) + return false + } + + const opencodeCmd = buildTmuxAttachCommand(serverUrl, sessionId, directory) + const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd]) + if (result.exitCode !== 0) { + log("[activateTmuxPane] FAILED", { paneId, sessionId, exitCode: result.exitCode, stderr: result.stderr.trim() }) + return false + } + + log("[activateTmuxPane] SUCCESS", { paneId, sessionId }) + return true +} diff --git a/src/shared/tmux/tmux-utils/pane-command.test.ts b/src/shared/tmux/tmux-utils/pane-command.test.ts new file mode 100644 index 000000000..00c192015 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-command.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test" +import { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./pane-command" + +describe("buildTmuxAttachCommand", () => { + it("escapes serverUrl shell metacharacters", () => { + const cmd = buildTmuxAttachCommand("http://localhost:3000$(whoami);rm -rf /", "ses_abc123") + expect(cmd).toContain("\\$") + expect(cmd).toContain("\\;") + expect(cmd).not.toMatch(/[^\\];\s*rm/) + }) + + it("escapes session id shell metacharacters", () => { + const cmd = buildTmuxAttachCommand("http://localhost:3000", 'ses_abc"$(whoami)"') + expect(cmd).toContain('\\"') + expect(cmd).toContain("\\$") + }) +}) + +describe("buildTmuxPlaceholderCommand", () => { + it("produces inert placeholder command instead of immediate attach", () => { + const cmd = buildTmuxPlaceholderCommand("My Task") + expect(cmd).toContain("Focus this pane to attach.") + expect(cmd).toContain("tail -f /dev/null") + expect(cmd).not.toContain("opencode attach") + }) + + it("keeps single quotes and percent signs inside safe printf arguments", () => { + const cmd = buildTmuxPlaceholderCommand("Fix Bob's 100% broken pane") + expect(cmd).toContain(`printf '%s\\n%s\\n'`) + expect(cmd).toContain(`"OMO subagent pane ready: Fix Bob's 100% broken pane"`) + }) +}) diff --git a/src/shared/tmux/tmux-utils/pane-command.ts b/src/shared/tmux/tmux-utils/pane-command.ts new file mode 100644 index 000000000..7de8efbbe --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-command.ts @@ -0,0 +1,15 @@ +import { shellEscapeForDoubleQuotedCommand } from "../../shell-env" + +const TMUX_COMMAND_SHELL = "/bin/sh" + +export function buildTmuxAttachCommand(serverUrl: string, sessionId: string, directory: string): string { + const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl) + const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId) + const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory || process.cwd()) + return `${TMUX_COMMAND_SHELL} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"` +} + +export function buildTmuxPlaceholderCommand(description: string): string { + const escapedDescription = shellEscapeForDoubleQuotedCommand(description) + return `${TMUX_COMMAND_SHELL} -c "printf '%s\\n%s\\n' \"OMO subagent pane ready: ${escapedDescription}\" \"Focus this pane to attach.\"; exec tail -f /dev/null"` +} diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts index 623de69a5..c6213c98a 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -3,7 +3,7 @@ import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" import type { runTmuxCommand as RunTmuxCommand } from "../runner" import { isInsideTmux } from "./environment" -import { shellSingleQuote } from "../../shell-env" +import { buildTmuxPlaceholderCommand } from "./pane-command" type ReplaceTmuxPaneDeps = { log: (message: string, data?: unknown) => void @@ -32,8 +32,8 @@ export async function replaceTmuxPane( sessionId: string, description: string, config: TmuxConfig, - serverUrl: string, - directory: string, + _serverUrl: string, + _directory: string, depsInput?: Partial, ): Promise { const deps = await resolveReplaceTmuxPaneDeps(depsInput) @@ -56,10 +56,9 @@ export async function replaceTmuxPane( log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"]) - const effectiveDirectory = directory || process.cwd() - const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` + const placeholderCmd = buildTmuxPlaceholderCommand(description) - const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd]) + const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, placeholderCmd]) if (result.exitCode !== 0) { log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.stderr.trim() }) diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts index 87e845a73..97fb8ec2b 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -5,7 +5,7 @@ import type { runTmuxCommand as RunTmuxCommand } from "../runner" import type { SplitDirection } from "./environment" import { isInsideTmux } from "./environment" import { isServerRunning } from "./server-health" -import { shellSingleQuote } from "../../shell-env" +import { buildTmuxPlaceholderCommand } from "./pane-command" type SpawnTmuxPaneDeps = { log: (message: string, data?: unknown) => void @@ -36,7 +36,7 @@ export async function spawnTmuxPane( description: string, config: TmuxConfig, serverUrl: string, - directory: string, + _directory: string, targetPaneId?: string, splitDirection: SplitDirection = "-h", depsInput?: Partial, @@ -76,8 +76,7 @@ export async function spawnTmuxPane( log("[spawnTmuxPane] all checks passed, spawning...") - const effectiveDirectory = directory || process.cwd() - const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` + const placeholderCmd = buildTmuxPlaceholderCommand(description) const args = [ "split-window", @@ -87,7 +86,7 @@ export async function spawnTmuxPane( "-F", "#{pane_id}", ...(targetPaneId ? ["-t", targetPaneId] : []), - opencodeCmd, + placeholderCmd, ] const result = await runTmuxCommand(tmux, args) From c63108d55b6fea3b076768946d8e1689993ecd56 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 16:39:43 +0800 Subject: [PATCH 02/10] fix(tmux): track placeholder panes before attach readiness --- .../tmux-subagent/polling-manager.test.ts | 121 ++++++++++++++++++ src/features/tmux-subagent/polling-manager.ts | 26 +++- .../tmux-subagent/tracked-session-state.ts | 1 + src/features/tmux-subagent/types.ts | 1 + src/shared/tmux/tmux-utils/session-spawn.ts | 11 +- src/shared/tmux/tmux-utils/window-spawn.ts | 9 +- 6 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index e3ed4e767..69526610c 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -336,4 +336,125 @@ describe("TmuxPollingManager overlap", () => { expect(closedSessionIds).toEqual([]) expect(sessions.has("ses-1")).toBe(true) }) + + test("does not close immediately when first status is delayed after focused activation", async () => { + //#given + const originalDateNow = Date.now + let now = 0 + Date.now = () => now + + try { + const sessions = new Map() + const tracked: TrackedSession = { + sessionId: "ses-1", + paneId: "%1", + description: "test", + attachActivated: false, + createdAt: new Date(0), + lastSeenAt: new Date(0), + closePending: false, + closeRetryCount: 0, + } + sessions.set("ses-1", tracked) + + let activationCount = 0 + let statusCalls = 0 + const closedSessionIds: string[] = [] + const getWindowState = async (): Promise => ({ + windowWidth: 220, + windowHeight: 44, + mainPane: { paneId: "%0", width: 110, height: 44, left: 0, top: 0, title: "main", isActive: false }, + agentPanes: [{ paneId: "%1", width: 110, height: 44, left: 110, top: 0, title: "agent", isActive: true }], + }) + + const client = { + session: { + status: async () => { + statusCalls += 1 + now += 3_000 + if (statusCalls <= 3) { + return { data: {} } + } + return { data: { "ses-1": { type: "running" } } } + }, + messages: async () => ({ data: [] }), + }, + } + + const manager = new TmuxPollingManager( + client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + sessions, + async (sessionId) => { + closedSessionIds.push(sessionId) + }, + getWindowState, + async () => { + activationCount += 1 + return true + }, + ) + + //#when + const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + await pollSessions.call(manager) + await pollSessions.call(manager) + await pollSessions.call(manager) + await pollSessions.call(manager) + + //#then + expect(activationCount).toBe(1) + expect(tracked.attachActivated).toBe(true) + expect(closedSessionIds).toEqual([]) + expect(sessions.has("ses-1")).toBe(true) + } finally { + Date.now = originalDateNow + } + }) + + test("can still close non-activated sessions once status is idle and stable", async () => { + //#given + const sessions = new Map() + sessions.set("ses-1", { + sessionId: "ses-1", + paneId: "%1", + description: "test", + attachActivated: false, + createdAt: new Date(Date.now() - 15_000), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + }) + + const closedSessionIds: string[] = [] + const client = { + session: { + status: async () => ({ data: { "ses-1": { type: "idle" } } }), + messages: async () => ({ data: [] }), + }, + } + + const manager = new TmuxPollingManager( + client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + sessions, + async (sessionId) => { + closedSessionIds.push(sessionId) + }, + ) + + manager.handleEvent({ + type: "message.part.delta", + properties: { sessionID: "ses-1", field: "text", delta: "done" }, + }) + + //#when + const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + await pollSessions.call(manager) + await pollSessions.call(manager) + await pollSessions.call(manager) + await pollSessions.call(manager) + + //#then + expect(closedSessionIds).toEqual(["ses-1"]) + }) }) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 384ef7c2e..9c457a20c 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -2,6 +2,7 @@ import type { OpencodeClient } from "../../tools/delegate-task/types" import { POLL_INTERVAL_BACKGROUND_MS, SESSION_MISSING_GRACE_MS, + SESSION_READY_TIMEOUT_MS, SESSION_TIMEOUT_MS, } from "../../shared/tmux" import type { TrackedSession, WindowState } from "./types" @@ -76,15 +77,30 @@ export class TmuxPollingManager { const sessionsToClose: string[] = [] for (const [sessionId, tracked] of this.sessions.entries()) { - if (!tracked.attachActivated) { - log("[tmux-session-manager] skipping close checks for non-activated pane", { + const status = allStatuses[sessionId] + const elapsedMs = now - tracked.createdAt.getTime() + if (!tracked.attachActivated && !status) { + log("[tmux-session-manager] placeholder pane has not been activated yet; skipping close checks", { sessionId, paneId: tracked.paneId, + elapsedMs, + }) + continue + } + + const attachElapsedMs = tracked.attachActivatedAt + ? now - tracked.attachActivatedAt.getTime() + : undefined + if (tracked.attachActivated && !status && attachElapsedMs !== undefined && attachElapsedMs < SESSION_READY_TIMEOUT_MS) { + log("[tmux-session-manager] waiting for first post-activation session status", { + sessionId, + paneId: tracked.paneId, + attachElapsedMs, + graceMs: SESSION_READY_TIMEOUT_MS, }) continue } - const status = allStatuses[sessionId] const isIdle = status?.type === "idle" if (status) { @@ -93,8 +109,7 @@ export class TmuxPollingManager { const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0 const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS - const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS - const elapsedMs = now - tracked.createdAt.getTime() + const isTimedOut = elapsedMs > SESSION_TIMEOUT_MS let shouldCloseViaStability = false @@ -217,6 +232,7 @@ export class TmuxPollingManager { const activated = await this.activateSessionPane(tracked) if (activated) { tracked.attachActivated = true + tracked.attachActivatedAt = new Date() tracked.lastSeenAt = new Date() tracked.stableIdlePolls = 0 tracked.observedIdleActivityVersion = tracked.activityVersion diff --git a/src/features/tmux-subagent/tracked-session-state.ts b/src/features/tmux-subagent/tracked-session-state.ts index 9def399e9..383a6eba0 100644 --- a/src/features/tmux-subagent/tracked-session-state.ts +++ b/src/features/tmux-subagent/tracked-session-state.ts @@ -13,6 +13,7 @@ export function createTrackedSession(params: { paneId: params.paneId, description: params.description, attachActivated: false, + attachActivatedAt: undefined, createdAt: now, lastSeenAt: now, closePending: false, diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index 36cc4939d..27567afa5 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -3,6 +3,7 @@ export interface TrackedSession { paneId: string description: string attachActivated: boolean + attachActivatedAt?: Date createdAt: Date lastSeenAt: Date closePending: boolean diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index 333330bc5..27f68cc07 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -4,7 +4,7 @@ import type { SpawnPaneResult } from "../types" import type { runTmuxCommand as RunTmuxCommand } from "../runner" import { isInsideTmux } from "./environment" import { isServerRunning } from "./server-health" -import { shellSingleQuote } from "../../shell-env" +import { buildTmuxPlaceholderCommand } from "./pane-command" const ISOLATED_SESSION_NAME_PREFIX = "omo-agents" @@ -61,7 +61,7 @@ export async function spawnTmuxSession( description: string, config: TmuxConfig, serverUrl: string, - directory: string, + _directory: string, sourcePaneId?: string, depsInput?: Partial, ): Promise { @@ -98,8 +98,7 @@ export async function spawnTmuxSession( log("[spawnTmuxSession] all checks passed, creating isolated session...") - const effectiveDirectory = directory || process.cwd() - const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` + const placeholderCmd = buildTmuxPlaceholderCommand(description) const sizeArgs: string[] = [] if (sourcePaneId) { @@ -118,7 +117,7 @@ export async function spawnTmuxSession( "-t", isolatedSessionName, "-P", "-F", "#{pane_id}", - opencodeCmd, + placeholderCmd, ] : [ "new-session", @@ -127,7 +126,7 @@ export async function spawnTmuxSession( ...sizeArgs, "-P", "-F", "#{pane_id}", - opencodeCmd, + placeholderCmd, ] log("[spawnTmuxSession] spawning", { diff --git a/src/shared/tmux/tmux-utils/window-spawn.ts b/src/shared/tmux/tmux-utils/window-spawn.ts index 8c07faef9..b2eecd882 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.ts @@ -3,8 +3,8 @@ import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" import { isInsideTmux } from "./environment" import { isServerRunning } from "./server-health" -import { shellSingleQuote } from "../../shell-env" import type { runTmuxCommand as RunTmuxCommand } from "../runner" +import { buildTmuxPlaceholderCommand } from "./pane-command" const ISOLATED_WINDOW_NAME = "omo-agents" @@ -37,7 +37,7 @@ export async function spawnTmuxWindow( description: string, config: TmuxConfig, serverUrl: string, - directory: string, + _directory: string, depsInput?: Partial, ): Promise { const deps = await resolveSpawnTmuxWindowDeps(depsInput) @@ -73,8 +73,7 @@ export async function spawnTmuxWindow( log("[spawnTmuxWindow] all checks passed, creating isolated window...") - const effectiveDirectory = directory || process.cwd() - const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` + const placeholderCmd = buildTmuxPlaceholderCommand(description) const args = [ "new-window", @@ -82,7 +81,7 @@ export async function spawnTmuxWindow( "-n", ISOLATED_WINDOW_NAME, "-P", "-F", "#{pane_id}", - opencodeCmd, + placeholderCmd, ] const result = await runTmuxCommand(tmux, args) From 4e3684eb2afbd4a506becc30f6b7ff5db09e93ff Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 20:01:18 +0800 Subject: [PATCH 03/10] fix(tmux): gate isolated pane activation on visible client focus --- src/features/tmux-subagent/manager.ts | 6 ++++ .../tmux-subagent/pane-state-parser.test.ts | 23 +++++++++---- .../tmux-subagent/pane-state-parser.ts | 33 ++++++++++++++++--- .../tmux-subagent/pane-state-querier.test.ts | 10 ++++-- .../tmux-subagent/pane-state-querier.ts | 6 ++-- .../tmux-subagent/polling-manager.test.ts | 2 ++ src/features/tmux-subagent/polling-manager.ts | 8 +++++ src/features/tmux-subagent/types.ts | 2 ++ 8 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index f07f82c8b..3f9ca54e7 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -137,6 +137,7 @@ export class TmuxSessionManager { this.retryPendingCloses.bind(this), this.queryWindowStateSafely.bind(this), this.activateTrackedSessionPane.bind(this), + this.canAutoActivatePane.bind(this), ) this.deps.log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, @@ -385,6 +386,11 @@ export class TmuxSessionManager { return true } + private canAutoActivatePane(state: WindowState): boolean { + if (!this.isIsolated()) return true + return state.windowActive && state.sessionAttached + } + private async closeTrackedSessionPane(args: { tracked: TrackedSession state: WindowState diff --git a/src/features/tmux-subagent/pane-state-parser.test.ts b/src/features/tmux-subagent/pane-state-parser.test.ts index 991c3fd95..87839203e 100644 --- a/src/features/tmux-subagent/pane-state-parser.test.ts +++ b/src/features/tmux-subagent/pane-state-parser.test.ts @@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser" describe("parsePaneStateOutput", () => { it("rejects malformed integer fields", () => { // given - const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -17,7 +17,7 @@ describe("parsePaneStateOutput", () => { it("rejects negative integer fields", () => { // given - const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -28,7 +28,7 @@ describe("parsePaneStateOutput", () => { it("rejects empty integer fields", () => { // given - const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -39,7 +39,7 @@ describe("parsePaneStateOutput", () => { it("rejects non-binary active flags", () => { // given - const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -50,7 +50,7 @@ describe("parsePaneStateOutput", () => { it("rejects numeric active flags other than zero or one", () => { // given - const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -61,7 +61,18 @@ describe("parsePaneStateOutput", () => { it("rejects empty active flags", () => { // given - const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t\t120\t40\t1\t1\n" + + // when + const result = parsePaneStateOutput(stdout) + + // then + expect(result).toBe(null) + }) + + it("rejects malformed session attached field", () => { + // given + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\tnope\n" // when const result = parsePaneStateOutput(stdout) diff --git a/src/features/tmux-subagent/pane-state-parser.ts b/src/features/tmux-subagent/pane-state-parser.ts index 3ae6579d8..97e240ee2 100644 --- a/src/features/tmux-subagent/pane-state-parser.ts +++ b/src/features/tmux-subagent/pane-state-parser.ts @@ -1,10 +1,12 @@ import type { TmuxPaneInfo } from "./types" -const MANDATORY_PANE_FIELD_COUNT = 8 +const MANDATORY_PANE_FIELD_COUNT = 10 type ParsedPaneState = { windowWidth: number windowHeight: number + windowActive: boolean + sessionAttached: boolean panes: TmuxPaneInfo[] } @@ -12,6 +14,8 @@ type ParsedPaneLine = { pane: TmuxPaneInfo windowWidth: number windowHeight: number + windowActive: boolean + sessionAttached: boolean } type MandatoryPaneFields = [ @@ -23,6 +27,8 @@ type MandatoryPaneFields = [ activeString: string, windowWidthString: string, windowHeightString: string, + windowActiveString: string, + sessionAttachedString: string, ] export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { @@ -45,6 +51,8 @@ export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { return { windowWidth: latestPaneLine.windowWidth, windowHeight: latestPaneLine.windowHeight, + windowActive: latestPaneLine.windowActive, + sessionAttached: latestPaneLine.sessionAttached, panes: parsedPaneLines.map(({ pane }) => pane), } } @@ -54,7 +62,7 @@ function parsePaneLine(line: string): ParsedPaneLine | null { const mandatoryFields = getMandatoryPaneFields(fields) if (!mandatoryFields) return null - const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = mandatoryFields const width = parseInteger(widthString) const height = parseInteger(heightString) @@ -63,6 +71,8 @@ function parsePaneLine(line: string): ParsedPaneLine | null { const isActive = parseActiveValue(activeString) const windowWidth = parseInteger(windowWidthString) const windowHeight = parseInteger(windowHeightString) + const windowActive = parseActiveValue(windowActiveString) + const sessionAttached = parseAttachedValue(sessionAttachedString) if ( width === null || @@ -71,7 +81,9 @@ function parsePaneLine(line: string): ParsedPaneLine | null { top === null || isActive === null || windowWidth === null || - windowHeight === null + windowHeight === null || + windowActive === null || + sessionAttached === null ) { return null } @@ -88,13 +100,15 @@ function parsePaneLine(line: string): ParsedPaneLine | null { }, windowWidth, windowHeight, + windowActive, + sessionAttached, } } function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null - const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = fields if ( paneId === undefined || @@ -104,7 +118,9 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { topString === undefined || activeString === undefined || windowWidthString === undefined || - windowHeightString === undefined + windowHeightString === undefined || + windowActiveString === undefined || + sessionAttachedString === undefined ) { return null } @@ -118,6 +134,8 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { activeString, windowWidthString, windowHeightString, + windowActiveString, + sessionAttachedString, ] } @@ -133,3 +151,8 @@ function parseActiveValue(value: string): boolean | null { if (value === "0") return false return null } + +function parseAttachedValue(value: string): boolean | null { + if (!/^\d+$/.test(value)) return null + return Number.parseInt(value, 10) > 0 +} diff --git a/src/features/tmux-subagent/pane-state-querier.test.ts b/src/features/tmux-subagent/pane-state-querier.test.ts index 708889246..da3a6a45c 100644 --- a/src/features/tmux-subagent/pane-state-querier.test.ts +++ b/src/features/tmux-subagent/pane-state-querier.test.ts @@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser" describe("parsePaneStateOutput", () => { it("accepts a single pane when tmux omits the empty trailing title field", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\n" // when const result = parsePaneStateOutput(stdout) @@ -16,6 +16,8 @@ describe("parsePaneStateOutput", () => { expect(result).toEqual({ windowWidth: 120, windowHeight: 40, + windowActive: true, + sessionAttached: true, panes: [ { paneId: "%0", @@ -32,7 +34,7 @@ describe("parsePaneStateOutput", () => { it("handles CRLF line endings without dropping panes", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\r\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent\r\n" // when const result = parsePaneStateOutput(stdout) @@ -63,13 +65,15 @@ describe("parsePaneStateOutput", () => { it("preserves tabs inside pane titles", () => { // given - const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n" + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t0\t0\ttitle\twith\ttabs\n" // when const result = parsePaneStateOutput(stdout) // then expect(result).not.toBe(null) + expect(result?.windowActive).toBe(false) + expect(result?.sessionAttached).toBe(false) expect(result?.panes[0]?.title).toBe("title\twith\ttabs") }) }) diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index 3dfa911ef..1b0c1a104 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -19,7 +19,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query "-t", sourcePaneId, "-F", - "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}", + "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}", ]) if (result.exitCode !== 0) { @@ -38,6 +38,8 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query const { panes } = parsedPaneState const windowWidth = parsedPaneState.windowWidth const windowHeight = parsedPaneState.windowHeight + const windowActive = parsedPaneState.windowActive + const sessionAttached = parsedPaneState.sessionAttached panes.sort((a, b) => a.left - b.left || a.top - b.top) @@ -71,7 +73,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query agentPaneCount: agentPanes.length, }) - return { windowWidth, windowHeight, mainPane, agentPanes } + return { windowWidth, windowHeight, windowActive, sessionAttached, mainPane, agentPanes } } export async function queryWindowState(sourcePaneId: string): Promise { diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index 69526610c..f9dd28359 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -269,6 +269,8 @@ describe("TmuxPollingManager overlap", () => { const windowState: WindowState = { windowWidth: 160, windowHeight: 48, + windowActive: true, + sessionAttached: true, mainPane: null, agentPanes: [ { paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true }, diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 9c457a20c..5d22297c1 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -24,6 +24,7 @@ export class TmuxPollingManager { private retryPendingCloses?: () => Promise, private getWindowState?: () => Promise, private activateSessionPane?: (tracked: TrackedSession) => Promise, + private canActivatePane: (state: WindowState) => boolean = (state) => state.windowActive !== false && state.sessionAttached !== false, ) {} handleEvent(event: { type: string; properties?: Record }): void { @@ -220,6 +221,13 @@ export class TmuxPollingManager { const state = await this.getWindowState().catch(() => null) if (!state) return + if (this.canActivatePane && !this.canActivatePane(state)) { + log("[tmux-session-manager] activation gate blocked auto-attach", { + windowActive: state.windowActive, + sessionAttached: state.sessionAttached, + }) + return + } const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable => Boolean(pane)) const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId)) diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index 27567afa5..9d120088a 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -31,6 +31,8 @@ export interface TmuxPaneInfo { export interface WindowState { windowWidth: number windowHeight: number + windowActive?: boolean + sessionAttached?: boolean mainPane: TmuxPaneInfo | null agentPanes: TmuxPaneInfo[] } From 0c8e546c57471865bde1062558278c812a878229 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 20:50:54 +0800 Subject: [PATCH 04/10] fix(tmux): support manager-scoped isolated session names --- src/features/tmux-subagent/manager.ts | 21 +++++++++++++++++++-- src/shared/tmux/tmux-utils/session-spawn.ts | 9 ++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 3f9ca54e7..be6f5d808 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -77,6 +77,13 @@ const FAILED_READINESS_SWEEP_INTERVAL_MS = 60 * 1000 const MAX_DEFERRED_QUEUE_SIZE = 20 const MAX_CLOSE_RETRY_COUNT = 3 const MAX_ISOLATED_CONTAINER_NULL_STATE_COUNT = 2 +let nextIsolatedSessionManagerId = 1 + +function createIsolatedSessionManagerId(): string { + const managerId = String(nextIsolatedSessionManagerId) + nextIsolatedSessionManagerId += 1 + return managerId +} export class TmuxSessionManager { private client: OpencodeClient @@ -102,6 +109,7 @@ export class TmuxSessionManager { private isolatedContainerNullStateCount = 0 private staleSweepCompleted = false private staleSweepInProgress = false + private isolatedSessionManagerId = createIsolatedSessionManagerId() constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: Partial = {}) { this.client = ctx.client this.tmuxConfig = tmuxConfig @@ -197,7 +205,16 @@ export class TmuxSessionManager { this.deps.log("[tmux-session-manager] creating isolated tmux container", { isolation, sessionId, title }) const result = isolation === "session" - ? await spawnTmuxSession(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory, this.sourcePaneId) + ? await spawnTmuxSession( + sessionId, + title, + this.tmuxConfig, + this.serverUrl, + this.projectDirectory, + this.sourcePaneId, + undefined, + this.isolatedSessionManagerId, + ) : await spawnTmuxWindow(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory) if (result.success && result.paneId) { @@ -1322,7 +1339,7 @@ export class TmuxSessionManager { this.isolatedWindowPaneId = undefined if (this.tmuxConfig.isolation === "session") { - const isolatedSessionName = getIsolatedSessionName() + const isolatedSessionName = getIsolatedSessionName(process.pid, this.isolatedSessionManagerId) try { const killed = await killTmuxSessionIfExists(isolatedSessionName) this.deps.log("[tmux-session-manager] isolated session teardown", { diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index 27f68cc07..11ca8e07a 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -32,8 +32,10 @@ async function resolveSpawnTmuxSessionDeps(deps?: Partial) } } -export function getIsolatedSessionName(pid: number = process.pid): string { - return `${ISOLATED_SESSION_NAME_PREFIX}-${pid}` +export function getIsolatedSessionName(pid: number = process.pid, managerId?: string): string { + return managerId + ? `${ISOLATED_SESSION_NAME_PREFIX}-${pid}-${managerId}` + : `${ISOLATED_SESSION_NAME_PREFIX}-${pid}` } async function getWindowDimensions( @@ -64,6 +66,7 @@ export async function spawnTmuxSession( _directory: string, sourcePaneId?: string, depsInput?: Partial, + managerId?: string, ): Promise { const deps = await resolveSpawnTmuxSessionDeps(depsInput) const { log, runTmuxCommand } = deps @@ -108,7 +111,7 @@ export async function spawnTmuxSession( } } - const isolatedSessionName = getIsolatedSessionName() + const isolatedSessionName = getIsolatedSessionName(process.pid, managerId) const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName, runTmuxCommand) const args = sessionAlreadyExists From 8c5ca73634996e24ee573672f69a4b4ee8a22917 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 20:50:54 +0800 Subject: [PATCH 05/10] fix(tmux): sweep suffixed stale isolated sessions --- .../tmux/tmux-utils/stale-session-sweep.test.ts | 17 +++++++++++++++-- .../tmux/tmux-utils/stale-session-sweep.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts b/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts index 66b8323de..74a651a74 100644 --- a/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts +++ b/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts @@ -99,6 +99,19 @@ describe("sweepStaleOmoAgentSessionsWith", () => { expect(fixture.killed).toEqual(["omo-agents-99991", "omo-agents-99992"]) }) + it("#given suffixed sessions with dead PIDs #when sweep called #then they are also killed", async () => { + // given + fixture.setCandidates(["omo-agents-99991-1", "omo-agents-99992-abc123"]) + fixture.setAlive(() => false) + + // when + const result = await sweepStaleOmoAgentSessionsWith(fixture.deps) + + // then + expect(result).toBe(2) + expect(fixture.killed).toEqual(["omo-agents-99991-1", "omo-agents-99992-abc123"]) + }) + it("#given session matches current PID #when sweep called #then it is NOT killed", async () => { // given fixture.setCandidates([`omo-agents-${fixture.deps.currentPid}`, "omo-agents-99999"]) @@ -139,9 +152,9 @@ describe("sweepStaleOmoAgentSessionsWith", () => { expect(fixture.killSessionMock).toHaveBeenCalledTimes(1) }) - it("#given non-matching sessions mixed in #when sweep called #then only omo-agents- sessions are considered", async () => { + it("#given non-matching sessions mixed in #when sweep called #then only supported omo-agents session names are considered", async () => { // given - fixture.setCandidates(["main", "omo-agents-99999", "other-session", "omo-agents-abc"]) + fixture.setCandidates(["main", "omo-agents-99999", "omo-agents-99999-1-2", "other-session", "omo-agents-abc"]) fixture.setAlive(() => false) // when diff --git a/src/shared/tmux/tmux-utils/stale-session-sweep.ts b/src/shared/tmux/tmux-utils/stale-session-sweep.ts index 48b88ad52..0ecb79110 100644 --- a/src/shared/tmux/tmux-utils/stale-session-sweep.ts +++ b/src/shared/tmux/tmux-utils/stale-session-sweep.ts @@ -1,4 +1,4 @@ -const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/ +const STALE_SESSION_PATTERN = /^omo-agents-(\d+)(?:-([A-Za-z0-9]+))?$/ function getErrorMessage(error: unknown): string { if (error instanceof Error) { From 91f1cf5fbcf3ce98d06e73600c81adc42f25657a Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 18 Apr 2026 21:20:49 +0800 Subject: [PATCH 06/10] fix(tmux): pin pane commands to /bin/sh --- .../tmux/tmux-utils/pane-command.test.ts | 26 +++++++++++++++++++ src/shared/tmux/tmux-utils/pane-command.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/shared/tmux/tmux-utils/pane-command.test.ts b/src/shared/tmux/tmux-utils/pane-command.test.ts index 00c192015..2c9cb7b1a 100644 --- a/src/shared/tmux/tmux-utils/pane-command.test.ts +++ b/src/shared/tmux/tmux-utils/pane-command.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "bun:test" import { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./pane-command" describe("buildTmuxAttachCommand", () => { + it("uses /bin/sh instead of inheriting SHELL", () => { + const originalShell = process.env.SHELL + process.env.SHELL = "/bin/tcsh" + + try { + const cmd = buildTmuxAttachCommand("http://localhost:3000", "ses_abc123") + expect(cmd.startsWith('/bin/sh -c "')).toBe(true) + expect(cmd).not.toContain("/bin/tcsh -c") + } finally { + process.env.SHELL = originalShell + } + }) + it("escapes serverUrl shell metacharacters", () => { const cmd = buildTmuxAttachCommand("http://localhost:3000$(whoami);rm -rf /", "ses_abc123") expect(cmd).toContain("\\$") @@ -17,6 +30,19 @@ describe("buildTmuxAttachCommand", () => { }) describe("buildTmuxPlaceholderCommand", () => { + it("uses /bin/sh instead of inheriting SHELL", () => { + const originalShell = process.env.SHELL + process.env.SHELL = "/bin/csh" + + try { + const cmd = buildTmuxPlaceholderCommand("My Task") + expect(cmd.startsWith('/bin/sh -c "')).toBe(true) + expect(cmd).not.toContain("/bin/csh -c") + } finally { + process.env.SHELL = originalShell + } + }) + it("produces inert placeholder command instead of immediate attach", () => { const cmd = buildTmuxPlaceholderCommand("My Task") expect(cmd).toContain("Focus this pane to attach.") diff --git a/src/shared/tmux/tmux-utils/pane-command.ts b/src/shared/tmux/tmux-utils/pane-command.ts index 7de8efbbe..101dd4d3c 100644 --- a/src/shared/tmux/tmux-utils/pane-command.ts +++ b/src/shared/tmux/tmux-utils/pane-command.ts @@ -2,7 +2,7 @@ import { shellEscapeForDoubleQuotedCommand } from "../../shell-env" const TMUX_COMMAND_SHELL = "/bin/sh" -export function buildTmuxAttachCommand(serverUrl: string, sessionId: string, directory: string): string { +export function buildTmuxAttachCommand(serverUrl: string, sessionId: string, directory: string = process.cwd()): string { const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl) const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId) const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory || process.cwd()) From 54a7256a7123c593cdd042bdfdbf0b26a4dc3c57 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:14:06 +0900 Subject: [PATCH 07/10] test(tmux): align placeholder command expectations --- .../tmux-subagent/polling-manager.test.ts | 13 ++++++------ .../tmux/tmux-utils/pane-spawn-runner.test.ts | 21 +++++++++++-------- .../tmux/tmux-utils/session-spawn.test.ts | 21 +++++++++++-------- .../tmux/tmux-utils/window-spawn.test.ts | 21 +++++++++++-------- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/features/tmux-subagent/polling-manager.test.ts b/src/features/tmux-subagent/polling-manager.test.ts index f9dd28359..76496e39d 100644 --- a/src/features/tmux-subagent/polling-manager.test.ts +++ b/src/features/tmux-subagent/polling-manager.test.ts @@ -298,7 +298,7 @@ describe("TmuxPollingManager overlap", () => { expect(tracked.attachActivated).toBe(true) }) - test("does not close non-activated panes before focus activation", async () => { + test("does not close non-activated panes before they report any session status", async () => { //#given const sessions = new Map() sessions.set("ses-1", { @@ -318,7 +318,7 @@ describe("TmuxPollingManager overlap", () => { const closedSessionIds: string[] = [] const client = { session: { - status: async () => ({ data: { "ses-1": { type: "idle" } } }), + status: async () => ({ data: {} }), messages: async () => ({ data: [] }), }, } @@ -384,11 +384,12 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) }, + undefined, getWindowState, async () => { activationCount += 1 @@ -397,7 +398,7 @@ describe("TmuxPollingManager overlap", () => { ) //#when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = unsafeTestValue<{ pollSessions: () => Promise }>(manager).pollSessions await pollSessions.call(manager) await pollSessions.call(manager) await pollSessions.call(manager) @@ -437,7 +438,7 @@ describe("TmuxPollingManager overlap", () => { } const manager = new TmuxPollingManager( - client as unknown as import("../../tools/delegate-task/types").OpencodeClient, + unsafeTestValue(client), sessions, async (sessionId) => { closedSessionIds.push(sessionId) @@ -450,7 +451,7 @@ describe("TmuxPollingManager overlap", () => { }) //#when - const pollSessions = (manager as unknown as { pollSessions: () => Promise }).pollSessions + const pollSessions = unsafeTestValue<{ pollSessions: () => Promise }>(manager).pollSessions await pollSessions.call(manager) await pollSessions.call(manager) await pollSessions.call(manager) diff --git a/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts index 5c2b2b96f..7df92b841 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts @@ -115,21 +115,23 @@ describe("spawnTmuxPane runner integration", () => { expect(result).toEqual({ success: true, paneId: "%42" }) expect(firstCall[1].slice(0, 8)).toEqual(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"]) expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) - expect(getSplitWindowCommand()).toContain(` --dir '${directory}'`) + expect(getSplitWindowCommand()).toContain("Focus this pane to attach.") + expect(getSplitWindowCommand()).toContain("tail -f /dev/null") + expect(getSplitWindowCommand()).not.toContain("opencode attach") }) - it("#given directory with spaces #when spawnTmuxPane called #then wraps --dir value in single quotes", async () => { + it("#given description with spaces #when spawnTmuxPane called #then includes it in the placeholder", async () => { // given const spawnTmuxPane = await loadSpawnTmuxPane() // when - await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps()) + await spawnTmuxPane("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps()) // then - expect(getSplitWindowCommand()).toContain("--dir '/path with spaces/here'") + expect(getSplitWindowCommand()).toContain("OMO subagent pane ready: worker with spaces") }) - it("#given empty directory #when spawnTmuxPane called #then falls back to process cwd", async () => { + it("#given empty directory #when spawnTmuxPane called #then keeps the placeholder detached from attach", async () => { // given const spawnTmuxPane = await loadSpawnTmuxPane() @@ -137,17 +139,18 @@ describe("spawnTmuxPane runner integration", () => { await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", "-h", createDeps()) // then - expect(getSplitWindowCommand()).toContain(`--dir '${process.cwd()}'`) + expect(getSplitWindowCommand()).not.toContain("--dir") }) - it("#given directory with single quotes #when spawnTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => { + it("#given description with shell metacharacters #when spawnTmuxPane called #then escapes the placeholder", async () => { // given const spawnTmuxPane = await loadSpawnTmuxPane() // when - await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps()) + await spawnTmuxPane("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps()) // then - expect(getSplitWindowCommand()).toContain("--dir '/path/with'\\''quote'") + expect(getSplitWindowCommand()).toContain('\\"') + expect(getSplitWindowCommand()).toContain("\\$") }) }) diff --git a/src/shared/tmux/tmux-utils/session-spawn.test.ts b/src/shared/tmux/tmux-utils/session-spawn.test.ts index 689ad7c5d..9958e883d 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.test.ts @@ -102,21 +102,23 @@ describe("spawnTmuxSession runner integration", () => { expect(newSessionCall[1].slice(0, 4)).toEqual(["new-session", "-d", "-s", newSessionCall[1][3]]) expect(String(newSessionCall[1][3]).startsWith("omo-agents-")).toBe(true) expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) - expect(harness.getSpawnCommand()).toContain(` --dir '${directory}'`) + expect(harness.getSpawnCommand()).toContain("Focus this pane to attach.") + expect(harness.getSpawnCommand()).toContain("tail -f /dev/null") + expect(harness.getSpawnCommand()).not.toContain("opencode attach") }) - it("#given directory with spaces #when spawnTmuxSession called #then wraps --dir value in single quotes", async () => { + it("#given description with spaces #when spawnTmuxSession called #then includes it in the placeholder", async () => { // given const harness = createHarness() // when - await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", harness.deps) + await spawnTmuxSession("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", harness.deps) // then - expect(harness.getSpawnCommand()).toContain("--dir '/path with spaces/here'") + expect(harness.getSpawnCommand()).toContain("OMO subagent pane ready: worker with spaces") }) - it("#given empty directory #when spawnTmuxSession called #then falls back to process cwd", async () => { + it("#given empty directory #when spawnTmuxSession called #then keeps the placeholder detached from attach", async () => { // given const harness = createHarness() @@ -124,17 +126,18 @@ describe("spawnTmuxSession runner integration", () => { await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", harness.deps) // then - expect(harness.getSpawnCommand()).toContain(`--dir '${process.cwd()}'`) + expect(harness.getSpawnCommand()).not.toContain("--dir") }) - it("#given directory with single quotes #when spawnTmuxSession called #then escapes the value with POSIX-safe single quoting", async () => { + it("#given description with shell metacharacters #when spawnTmuxSession called #then escapes the placeholder", async () => { // given const harness = createHarness() // when - await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", harness.deps) + await spawnTmuxSession("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", harness.deps) // then - expect(harness.getSpawnCommand()).toContain("--dir '/path/with'\\''quote'") + expect(harness.getSpawnCommand()).toContain('\\"') + expect(harness.getSpawnCommand()).toContain("\\$") }) }) diff --git a/src/shared/tmux/tmux-utils/window-spawn.test.ts b/src/shared/tmux/tmux-utils/window-spawn.test.ts index 03d7e46ef..4f0d18b09 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.test.ts @@ -90,21 +90,23 @@ describe("spawnTmuxWindow runner integration", () => { expect(result).toEqual({ success: true, paneId: "%42" }) expect(firstCall[1].slice(0, 7)).toEqual(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"]) expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) - expect(harness.getNewWindowCommand()).toContain(` --dir '${directory}'`) + expect(harness.getNewWindowCommand()).toContain("Focus this pane to attach.") + expect(harness.getNewWindowCommand()).toContain("tail -f /dev/null") + expect(harness.getNewWindowCommand()).not.toContain("opencode attach") }) - it("#given directory with spaces #when spawnTmuxWindow called #then wraps --dir value in single quotes", async () => { + it("#given description with spaces #when spawnTmuxWindow called #then includes it in the placeholder", async () => { // given const harness = createHarness() // when - await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", harness.deps) + await spawnTmuxWindow("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", harness.deps) // then - expect(harness.getNewWindowCommand()).toContain("--dir '/path with spaces/here'") + expect(harness.getNewWindowCommand()).toContain("OMO subagent pane ready: worker with spaces") }) - it("#given empty directory #when spawnTmuxWindow called #then falls back to process cwd", async () => { + it("#given empty directory #when spawnTmuxWindow called #then keeps the placeholder detached from attach", async () => { // given const harness = createHarness() @@ -112,17 +114,18 @@ describe("spawnTmuxWindow runner integration", () => { await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", harness.deps) // then - expect(harness.getNewWindowCommand()).toContain(`--dir '${process.cwd()}'`) + expect(harness.getNewWindowCommand()).not.toContain("--dir") }) - it("#given directory with single quotes #when spawnTmuxWindow called #then escapes the value with POSIX-safe single quoting", async () => { + it("#given description with shell metacharacters #when spawnTmuxWindow called #then escapes the placeholder", async () => { // given const harness = createHarness() // when - await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", harness.deps) + await spawnTmuxWindow("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", harness.deps) // then - expect(harness.getNewWindowCommand()).toContain("--dir '/path/with'\\''quote'") + expect(harness.getNewWindowCommand()).toContain('\\"') + expect(harness.getNewWindowCommand()).toContain("\\$") }) }) From d02cca4422c4c2742d1dbc36ecfc6761507d42a9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:18:33 +0900 Subject: [PATCH 08/10] test(tmux): align pane replace placeholder expectations --- .../tmux/tmux-utils/pane-replace.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/shared/tmux/tmux-utils/pane-replace.test.ts b/src/shared/tmux/tmux-utils/pane-replace.test.ts index 57416a745..65dee7294 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.test.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.test.ts @@ -112,21 +112,23 @@ describe("replaceTmuxPane runner integration", () => { expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"]) expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"]) expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) - expect(getRespawnCommand()).toContain(` --dir '${directory}'`) + expect(getRespawnCommand()).toContain("Focus this pane to attach.") + expect(getRespawnCommand()).toContain("tail -f /dev/null") + expect(getRespawnCommand()).not.toContain("opencode attach") }) - it("#given directory with spaces #when replaceTmuxPane called #then wraps --dir value in single quotes", async () => { + it("#given description with spaces #when replaceTmuxPane called #then includes it in the placeholder", async () => { // given const replaceTmuxPane = await loadReplaceTmuxPane() // when - await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps()) + await replaceTmuxPane("%42", "session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps()) // then - expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'") + expect(getRespawnCommand()).toContain("OMO subagent pane ready: worker with spaces") }) - it("#given empty directory #when replaceTmuxPane called #then falls back to process cwd", async () => { + it("#given empty directory #when replaceTmuxPane called #then keeps the placeholder detached from attach", async () => { // given const replaceTmuxPane = await loadReplaceTmuxPane() @@ -134,17 +136,18 @@ describe("replaceTmuxPane runner integration", () => { await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps()) // then - expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`) + expect(getRespawnCommand()).not.toContain("--dir") }) - it("#given directory with single quotes #when replaceTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => { + it("#given description with shell metacharacters #when replaceTmuxPane called #then escapes the placeholder", async () => { // given const replaceTmuxPane = await loadReplaceTmuxPane() // when - await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps()) + await replaceTmuxPane("%42", "session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps()) // then - expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'") + expect(getRespawnCommand()).toContain('\\"') + expect(getRespawnCommand()).toContain("\\$") }) }) From 1ff59f44ce28731472defd17ebf621813b73f69e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:22:52 +0900 Subject: [PATCH 09/10] test(tmux): align pane-state runner format --- .../tmux-subagent/pane-state-querier-runner.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/tmux-subagent/pane-state-querier-runner.test.ts b/src/features/tmux-subagent/pane-state-querier-runner.test.ts index a51e72d32..468645357 100644 --- a/src/features/tmux-subagent/pane-state-querier-runner.test.ts +++ b/src/features/tmux-subagent/pane-state-querier-runner.test.ts @@ -21,8 +21,8 @@ describe("queryWindowState runner integration", () => { runTmuxCommandMock.mockResolvedValue({ success: true, - output: "%0\t120\t40\t0\t0\t1\t120\t40\t\n%1\t60\t40\t60\t0\t0\t120\t40\tagent", - stdout: "%0\t120\t40\t0\t0\t1\t120\t40\t\n%1\t60\t40\t60\t0\t0\t120\t40\tagent", + output: "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\t\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent", + stdout: "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\t\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent", stderr: "", exitCode: 0, }) @@ -52,7 +52,7 @@ describe("queryWindowState runner integration", () => { "-t", "%0", "-F", - "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}", + "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}", ], ], ]) From e63c5b9a22b4f14846b0adfc543d256b1926e132 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:26:44 +0900 Subject: [PATCH 10/10] fix(tmux): require explicit active isolated window --- src/features/tmux-subagent/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index be6f5d808..0ff05a594 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -405,7 +405,7 @@ export class TmuxSessionManager { private canAutoActivatePane(state: WindowState): boolean { if (!this.isIsolated()) return true - return state.windowActive && state.sessionAttached + return state.windowActive === true && state.sessionAttached === true } private async closeTrackedSessionPane(args: {