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
253 changes: 215 additions & 38 deletions src/utils/swarm/backends/WindowsTerminalBackend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'
import { readFile } from 'fs/promises'
import { readFile, unlink } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
Expand All @@ -13,10 +13,15 @@ import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
type CommandResult = { stdout: string; stderr: string; code: number }
type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>

type PaneStatus = 'registered' | 'spawning' | 'ready' | 'killing' | 'dead'

type WindowsTerminalPane = {
title: string
mode: 'pane' | 'window'
pidFile: string
status: PaneStatus
pid?: number
spawnPromise?: Promise<void>
}

function quotePowerShellString(value: string): string {
Expand All @@ -39,8 +44,42 @@ function wrapPowerShellCommand(command: string, pidFile: string): string {
].join('; ')
}

function makePidFile(paneId: string): string {
return join(tmpdir(), `${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`)
const WT_PANE_TIMEOUT_DEFAULT_MS = 8000
const WT_PANE_POLL_INTERVAL_MS = 200

function getWtPaneTimeoutMs(): number {
const raw = process.env.CLAUDE_WT_PANE_TIMEOUT_MS
if (!raw) return WT_PANE_TIMEOUT_DEFAULT_MS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: WT_PANE_TIMEOUT_DEFAULT_MS
}

async function waitForPidFile(
pidFile: string,
timeoutMs: number,
): Promise<number> {
const deadline = Date.now() + timeoutMs
let lastErr: unknown
while (Date.now() < deadline) {
try {
const content = (await readFile(pidFile, 'utf-8')).trim()
if (!/^\d+$/.test(content)) {
lastErr = new Error(
`pidFile content not a valid pid: ${JSON.stringify(content)}`,
)
} else {
const pid = Number.parseInt(content, 10)
if (Number.isFinite(pid) && pid > 0) return pid
lastErr = new Error(`pidFile content parsed to invalid pid: ${pid}`)
}
} catch (err) {
lastErr = err
}
await new Promise(r => setTimeout(r, WT_PANE_POLL_INTERVAL_MS))
}
throw lastErr ?? new Error('pidFile never appeared')
}

/**
Expand All @@ -58,10 +97,40 @@ export class WindowsTerminalBackend implements PaneBackend {

private panes = new Map<PaneId, WindowsTerminalPane>()

private readonly runCommand: CommandRunner
private readonly getPlatformValue: () => Platform
private readonly pidFileDir: string

constructor(
private readonly runCommand: CommandRunner = execFileNoThrow,
private readonly getPlatformValue: () => Platform = getPlatform,
) {}
runCommandOrOptions?:
| CommandRunner
| {
runCommand?: CommandRunner
getPlatform?: () => Platform
pidFileDir?: string
},
getPlatformValue?: () => Platform,
) {
if (
typeof runCommandOrOptions === 'function' ||
runCommandOrOptions === undefined
) {
this.runCommand = runCommandOrOptions ?? execFileNoThrow
this.getPlatformValue = getPlatformValue ?? getPlatform
this.pidFileDir = tmpdir()
} else {
this.runCommand = runCommandOrOptions.runCommand ?? execFileNoThrow
this.getPlatformValue = runCommandOrOptions.getPlatform ?? getPlatform
this.pidFileDir = runCommandOrOptions.pidFileDir ?? tmpdir()
}
}

private makePidFile(paneId: string): string {
return join(
this.pidFileDir,
`${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`,
)
}

async isAvailable(): Promise<boolean> {
if (this.getPlatformValue() !== 'windows') {
Expand Down Expand Up @@ -92,7 +161,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, {
title: name,
mode: 'pane',
pidFile: makePidFile(paneId),
pidFile: this.makePidFile(paneId),
status: 'registered',
})
return { paneId, isFirstTeammate }
}
Expand All @@ -106,7 +176,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, {
title: name,
mode: 'window',
pidFile: makePidFile(paneId),
pidFile: this.makePidFile(paneId),
status: 'registered',
})
return { paneId, isFirstTeammate: false, windowName }
}
Expand All @@ -121,32 +192,95 @@ export class WindowsTerminalBackend implements PaneBackend {
throw new Error(`Unknown Windows Terminal pane id: ${paneId}`)
}

const launcher = wrapPowerShellCommand(command, pane.pidFile)
// wt.exe treats ';' as its own command separator, which breaks
// multi-statement PowerShell commands passed via -Command. Encode the
// entire script as Base64 UTF-16LE and use -EncodedCommand instead.
const encoded = Buffer.from(launcher, 'utf16le').toString('base64')
const args =
pane.mode === 'window'
? ['-w', '-1', 'new-tab', '--title', pane.title]
: ['-w', '0', 'split-pane', '--vertical', '--title', pane.title]

const result = await this.runCommand('wt.exe', [
...args,
'powershell.exe',
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded,
])

if (result.code !== 0) {
// 拒绝 ready 态重 spawn(避免同 pidFile 双进程竞争)
if (pane.status === 'ready' || pane.status === 'killing') {
throw new Error(
`Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`,
`Pane ${paneId} already spawned (status=${pane.status}); create a new pane to re-launch`,
)
}
if (pane.status === 'spawning') {
throw new Error(
`Pane ${paneId} is currently spawning; wait for the in-flight launch to complete`,
)
}
if (pane.status === 'dead') {
throw new Error(`Pane ${paneId} is dead; create a new pane`)
}
// pane.status === 'registered' → 继续

// 提前赋值 spawnPromise 在任何 await 前(inner Promise 包装)
// Attach a no-op .catch() immediately to prevent unhandled rejection warnings
// in case killPane never awaits spawnPromise (e.g. sendCommandToPane fails
// before killPane is called).
let resolveSpawn!: () => void
let rejectSpawn!: (err: unknown) => void
const spawnPromise = new Promise<void>((res, rej) => {
resolveSpawn = res
rejectSpawn = rej
})
// Silence unhandled-rejection: killPane may .catch() this later, but if
// the pane dies before any kill is attempted, the rejection must not leak.
spawnPromise.catch(() => {})
pane.status = 'spawning'
pane.spawnPromise = spawnPromise

try {
const launcher = wrapPowerShellCommand(command, pane.pidFile)
// wt.exe treats ';' as its own command separator, which breaks
// multi-statement PowerShell commands passed via -Command. Encode the
// entire script as Base64 UTF-16LE and use -EncodedCommand instead.
const encoded = Buffer.from(launcher, 'utf16le').toString('base64')
const args =
pane.mode === 'window'
? ['-w', '-1', 'new-tab', '--title', pane.title]
: ['-w', '0', 'split-pane', '--vertical', '--title', pane.title]

await unlink(pane.pidFile).catch(() => {})

const result = await this.runCommand('wt.exe', [
...args,
'powershell.exe',
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded,
])

if (result.code !== 0) {
throw new Error(
`Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`,
)
}

const timeoutMs = getWtPaneTimeoutMs()
let pid: number
try {
pid = await waitForPidFile(pane.pidFile, timeoutMs)
} catch (err) {
throw new Error(
`Windows Terminal pane failed to launch within ${timeoutMs}ms\n` +
` paneId: ${paneId}\n` +
` pidFile: ${pane.pidFile}\n` +
` wt.exe stdout: ${result.stdout || '(empty)'}\n` +
` wt.exe stderr: ${result.stderr || '(empty)'}\n` +
` underlying: ${err instanceof Error ? err.message : String(err)}\n` +
` override timeout via env CLAUDE_WT_PANE_TIMEOUT_MS`,
)
}

pane.pid = pid
pane.status = 'ready'
resolveSpawn()
} catch (err) {
pane.status = 'dead'
pane.pid = undefined
rejectSpawn(err)
throw err
} finally {
pane.spawnPromise = undefined
}
}

async setPaneBorderColor(
Expand Down Expand Up @@ -189,26 +323,69 @@ export class WindowsTerminalBackend implements PaneBackend {
return false
}

let pid: number
try {
pid = Number.parseInt((await readFile(pane.pidFile, 'utf-8')).trim(), 10)
} catch {
this.panes.delete(paneId)
return false
// 1. 解 kill-while-spawn race:await spawn 完成(不论成功失败)
if (pane.status === 'spawning' && pane.spawnPromise) {
await pane.spawnPromise.catch(() => {})
}

if (!Number.isFinite(pid)) {
// 2. TOCTOU 修正:重读 status/pid
if (pane.status === 'dead') {
this.panes.delete(paneId)
return false
}
if (pane.status !== 'ready') {
// 还在其它非终态(理论不可达,保险)
return false
}

pane.status = 'killing'

// 3. 优先用缓存 pid
let pid: number | undefined = pane.pid

// 4. fallback:缓存没有则读盘(保留 retry 3×500ms)
if (pid === undefined) {
let pidContent: string | null = null
for (let attempt = 0; attempt < 3; attempt++) {
try {
pidContent = (await readFile(pane.pidFile, 'utf-8')).trim()
break
} catch {
if (attempt === 2) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
await new Promise(r => setTimeout(r, 500))
}
}
if (!pidContent || !/^\d+$/.test(pidContent)) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
const parsed = Number.parseInt(pidContent, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
pid = parsed
}

// 5. 执行 Stop-Process
const result = await this.runCommand('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-Command',
`Stop-Process -Id ${pid} -Force -ErrorAction Stop`,
])

// 6. 不管成功失败都清缓存 + 标 dead + 从 map 删(防 PID 复用误杀)
pane.pid = undefined
pane.status = 'dead'
this.panes.delete(paneId)

logForDebugging(
`[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`,
)
Expand Down
Loading