From ced95c4bfa11564c02a4760fdc831131c93d4b95 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sat, 16 May 2026 01:23:25 -0400 Subject: [PATCH] fix(team-mode): surface port-0 fallback and silent layout skip (#3963) When ctx.serverUrl had a port string of "0", TmuxSessionManager silently replaced it with the localhost:4096 fallback and createTeamLayout subsequently skipped pane creation without any user-visible signal. The two-step silent failure made team_mode tmux_visualization look broken in default TUI mode. Surface the failure path: - TmuxSessionManager now retains ctx.serverUrl on the instance and exposes it via getCtxServerUrl(), and emits a structured warning log on the port-0 fallback branch naming both the discarded URL and the fallback it landed on. - createTeamLayout's "opencode server not reachable" log is upgraded to a structured warning including ctxServerUrl and a hint to launch with --port N + OPENCODE_PORT=N. No behavior change to the fallback resolution itself - only the silence. Existing port-0 fallback tests still pass; two new tests assert the warning fires on port 0 and is absent for real ports. --- .../team-mode/team-layout-tmux/layout.ts | 12 +++- src/features/tmux-subagent/manager.test.ts | 63 +++++++++++++++++++ src/features/tmux-subagent/manager.ts | 18 +++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts index 2709b0603..c68ada2b4 100644 --- a/src/features/team-mode/team-layout-tmux/layout.ts +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -121,7 +121,17 @@ export async function createTeamLayout(teamRunId: string, members: Array { // then expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:4096') }) + + test('logs a structured warning when ctx.serverUrl has port 0 (#3963)', async () => { + // given + const previousOpenCodePort = process.env.OPENCODE_PORT + delete process.env.OPENCODE_PORT + const logCalls: Array<{ message: string; data?: unknown }> = [] + const trackingDeps: TmuxUtilDeps = { + ...mockTmuxDeps, + log: (message, data) => { logCalls.push({ message, data }) }, + } + try { + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = { + ...createMockContext(), + serverUrl: new URL('http://127.0.0.1:0/'), + } + const config = createTmuxConfig({ enabled: true }) + + // when + const manager = new TmuxSessionManager(ctx, config, trackingDeps) + + // then + const warning = logCalls.find((entry) => entry.message.includes('ctx.serverUrl has port 0')) + expect(warning).toBeDefined() + expect(warning?.data).toMatchObject({ + kind: 'warning', + ctxServerUrl: 'http://127.0.0.1:0/', + fallbackUrl: 'http://localhost:4096', + }) + expect(manager.getCtxServerUrl()).toBe('http://127.0.0.1:0/') + } finally { + if (previousOpenCodePort === undefined) { + delete process.env.OPENCODE_PORT + } else { + process.env.OPENCODE_PORT = previousOpenCodePort + } + } + }) + + test('does not warn when ctx.serverUrl has a real port', async () => { + // given + const logCalls: Array<{ message: string; data?: unknown }> = [] + const trackingDeps: TmuxUtilDeps = { + ...mockTmuxDeps, + log: (message, data) => { logCalls.push({ message, data }) }, + } + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = { + ...createMockContext(), + serverUrl: new URL('http://127.0.0.1:12345/'), + } + const config = createTmuxConfig({ enabled: true }) + + // when + const manager = new TmuxSessionManager(ctx, config, trackingDeps) + + // then + const warning = logCalls.find((entry) => entry.message.includes('ctx.serverUrl has port 0')) + expect(warning).toBeUndefined() + expect(manager.getCtxServerUrl()).toBe('http://127.0.0.1:12345/') + }) }) describe('getServerUrl', () => { diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 0ff05a594..579e4d167 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -90,6 +90,7 @@ export class TmuxSessionManager { private tmuxConfig: TmuxConfig private projectDirectory: string private serverUrl: string + private ctxServerUrl: string | undefined private sourcePaneId: string | undefined private sessions = new Map() private pendingSessions = new Set() @@ -122,11 +123,22 @@ export class TmuxSessionManager { : "4096" const fallbackUrl = `http://localhost:${defaultPort}` const rawServerUrl = ctx.serverUrl?.toString() + this.ctxServerUrl = rawServerUrl try { if (rawServerUrl) { const parsed = new URL(rawServerUrl) const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80') - this.serverUrl = port === '0' ? fallbackUrl : rawServerUrl + if (port === '0') { + this.deps.log( + "[tmux-session-manager] ctx.serverUrl has port 0; falling back. " + + "team_mode tmux visualization will silently skip if nothing is listening on the fallback URL. " + + "Launch opencode with --port N and OPENCODE_PORT=N to bind a real port (see issue #3963).", + { kind: "warning", ctxServerUrl: rawServerUrl, fallbackUrl }, + ) + this.serverUrl = fallbackUrl + } else { + this.serverUrl = rawServerUrl + } } else { this.serverUrl = fallbackUrl } @@ -256,6 +268,10 @@ export class TmuxSessionManager { return this.serverUrl } + getCtxServerUrl(): string | undefined { + return this.ctxServerUrl + } + private removeTrackedSession(sessionId: string): void { this.sessions.delete(sessionId)