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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,9 @@ BossTerm ships an in-process [Model Context Protocol](https://modelcontextprotoc
server that exposes the running terminal to MCP-aware clients (Claude Code,
Codex, Gemini CLI, OpenCode). Clients can enumerate tabs, read scrollback,
search output, capture the last completed command, and — when write tools
are enabled — drive shells, send signals, and open new splits.
are enabled — drive shells, send signals, open new splits, and **run
commands in a visible pane** while still capturing stdout/stderr and exit
code (`run_command` — recommended default shell for AI clients).

- **Endpoint**: `http://127.0.0.1:7676/` over Server-Sent Events, configurable
via Settings → BossTerm MCP → Port.
Expand All @@ -480,6 +482,18 @@ are enabled — drive shells, send signals, and open new splits.
register the endpoint with. Re-attachment is idempotent and happens
silently on subsequent launches.

### Using as Claude Code's default shell

Out of the box, the server's initialize-time `instructions` already tell
Claude Code to prefer `run_command` over its built-in `Bash` whenever the
MCP is attached — commands run in a visible BossTerm pane and the output
still comes back to the agent. For a hard guarantee, add the user-global
`PreToolUse` hook described in
[docs/mcp-server.md](docs/mcp-server.md#using-as-claude-codes-default-shell):
the hook checks the `~/.bossterm/mcp.port` marker BossTerm writes on every
successful bind and routes `Bash` calls to `mcp__bossterm__run_command` when
BossTerm is running, falling through silently when it isn't.

### Embedding it (as a developer)

```kotlin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,25 +601,58 @@ class TabbedTerminalState {
return performSplit(SplitOrientation.HORIZONTAL, tabId, ratio, initialCommand)
}

/**
* Split a specific pane (by id) vertically — the new pane appears to the
* right of [anchorPaneId]. Equivalent to focusing [anchorPaneId] then
* calling [splitVertical], but as one atomic operation. If the anchor
* pane doesn't exist, falls back to splitting the currently focused pane.
*
* Designed for the MCP tools (run_command / run_in_panel) so they can
* stack scratch panes horizontally in the bottom row instead of
* blindly splitting whatever pane happens to be focused.
*
* @return The session id of the new pane, or null if the split failed.
*/
fun splitVerticalFromPane(
tabId: String,
anchorPaneId: String,
ratio: Float? = null,
initialCommand: String? = null
): String? = performSplit(
SplitOrientation.VERTICAL, tabId, ratio, initialCommand, anchorPaneId
)

/**
* Internal helper to perform a split in the given orientation.
*
* @param initialCommand Optional command to run in the new pane once its shell is ready
* (OSC 133;A or fallback delay). Held by the session bootstrap so the bytes are not
* eaten by shell startup output (banner, rc-file sourcing, prompt draw).
* @param anchorPaneId Optional pane to split. When provided, that pane is
* focused before the split so [SplitViewState.splitFocusedPane] targets
* it. Ignored if the pane doesn't exist (falls back to current focus).
*/
private fun performSplit(
orientation: SplitOrientation,
tabId: String?,
ratio: Float? = null,
initialCommand: String? = null
initialCommand: String? = null,
anchorPaneId: String? = null
): String? {
val resolvedTabId = resolveTabId(tabId) ?: return null
val controller = tabController ?: return null
val tab = controller.getTabById(resolvedTabId) as? TerminalTab ?: return null
val splitState = getOrCreateSplitState(resolvedTabId) ?: return null
val settings = SettingsManager.instance.settings.value

// Anchor focus before split, when an explicit pane was requested.
// SplitViewState.setFocusedPane silently no-ops on unknown ids, so
// we read back focusedPaneId to confirm whether the redirect took
// effect — debug only; behavior degrades gracefully either way.
if (anchorPaneId != null) {
splitState.setFocusedPane(anchorPaneId)
}

val workingDir = if (settings.splitInheritWorkingDirectory) {
splitState.getFocusedSession()?.workingDirectory?.value
} else null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.File
import java.io.IOException
import java.net.BindException
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.nio.file.Files
import java.nio.file.StandardCopyOption

/**
* Lifecycle wrapper that brings up the BossTerm in-process MCP server on a
Expand Down Expand Up @@ -343,6 +346,23 @@ class BossTermMcpManager(
finish()
return@intercept
}

// Resolve which BossTerm window the calling client lives in
// (process-tree walk from the client's PID) and record it
// so tools that default to "primary window" target the
// caller's window rather than first-registered. Failure
// here is silent — the resolver returns null and the
// server keeps using the prior resolution (or
// primaryState() if there is none). This runs only
// AFTER the rebinding check passes, so we never spawn
// lsof for a hostile request.
val remotePort = call.request.local.remotePort
val resolved = try {
ProcessAncestry.resolveClientWindow(remotePort, registry)
} catch (_: Throwable) {
null
}
registry.setLastResolvedClientWindow(resolved)
}
// SDK 0.8.3 quirk: both `Route.mcp { ... }` and
// `Routing.mcp(path, ...) { ... }` end up mounting SSE +
Expand All @@ -360,6 +380,7 @@ class BossTermMcpManager(
runningPort = port
runningServer = mcpServerWrapper
registry.setRunning(port)
writePortMarker(port)
log.info(
"BossTerm MCP server ready: http://{}:{}{} (SSE transport, {} state(s) registered)",
HOST, port, PATH, registry.stateCount()
Expand Down Expand Up @@ -461,9 +482,51 @@ class BossTermMcpManager(
runningPort = null
runningServer = null
registry.setStopped()
deletePortMarker()
}
}

/**
* Atomic write of the bound port to `~/.bossterm/mcp.port` so the user-global
* Claude Code `PreToolUse` hook can decide whether to route `Bash` through
* `mcp__bossterm__run_command` with a single stat + `nc -z` instead of an
* HTTP probe (~5ms vs ~300ms worst case per Bash call).
*
* Reflects the *actual* bound port, including the 7676→7685 fallback range,
* so the hook doesn't need to know about fallback. Best-effort: any I/O
* failure is logged at WARN and ignored — the marker is an optimization,
* not a correctness lever.
*/
private fun writePortMarker(port: Int) {
try {
val target = mcpPortMarkerFile()
target.parentFile?.mkdirs()
val tmp = File(target.parentFile, ".mcp.port.tmp")
tmp.writeText(port.toString())
// ATOMIC_MOVE so concurrent hook reads never see a partial file.
Files.move(
tmp.toPath(), target.toPath(),
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING
)
} catch (e: Throwable) {
log.warn("Failed to write MCP port marker: {}", e.message)
}
}

private fun deletePortMarker() {
try {
val target = mcpPortMarkerFile()
if (target.exists() && !target.delete()) {
log.warn("Failed to delete MCP port marker at {}", target)
}
} catch (e: Throwable) {
log.warn("Error while deleting MCP port marker: {}", e.message)
}
}

private fun mcpPortMarkerFile(): File =
File(System.getProperty("user.home"), ".bossterm/mcp.port")

private data class McpRuntimeConfig(val enabled: Boolean, val port: Int)

private companion object {
Expand Down
Loading
Loading