Skip to content
Draft
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
15 changes: 14 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ import { DEFAULT_PORT } from "kolu-common/config";
import { appRouter } from "./router.ts";
import { log } from "./log.ts";
import { initSessionAutoSave } from "./session.ts";
import { snapshotSession } from "./terminals.ts";
import {
snapshotSession,
listTerminals,
listTerminalsWithPaneIndex,
} from "./terminals.ts";
import { resolveTlsOptions } from "./tls.ts";
import { configureNixShellEnv } from "./shell.ts";
import { serverHostname } from "./hostname.ts";
import { initTmuxShim, cleanupTmuxShim } from "./tmux-env.ts";
import pkg from "../package.json" with { type: "json" };

const argv = cli({
Expand Down Expand Up @@ -62,6 +67,9 @@ const argv = cli({

configureNixShellEnv(argv.flags.allowNixShellWithEnvWhitelist);
initSessionAutoSave(snapshotSession);
initTmuxShim();
// Expose server port to PTY shells so the tmux shim can find the server
process.env.KOLU_PORT = String(argv.flags.port);
if (argv.flags.verbose) log.level = "debug";

const app = new Hono();
Expand Down Expand Up @@ -116,6 +124,7 @@ app.use("/rpc/*", async (c, next) => {
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
process.on(sig, () => {
log.info({ signal: sig }, "shutting down");
cleanupTmuxShim();
process.exit(0);
});
}
Expand All @@ -131,6 +140,10 @@ process.on("unhandledRejection", (reason) => {
// --- Health endpoint ---
app.get("/api/health", (c) => c.text("kolu"));

// --- Terminal list snapshot (non-streaming, for tmux shim) ---
// Includes tmuxPaneIndex so the shim can map %N pane IDs to terminal UUIDs.
app.get("/api/terminals", (c) => c.json(listTerminalsWithPaneIndex()));

// --- Dynamic PWA manifest (includes hostname) ---
// theme_color must match <meta name="theme-color"> in client/index.html
app.get("/manifest.webmanifest", (c) => {
Expand Down
13 changes: 11 additions & 2 deletions server/src/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as pty from "node-pty";
import { createRequire } from "node:module";
import {
DEFAULT_COLS,
DEFAULT_PORT,
DEFAULT_ROWS,
DEFAULT_SCROLLBACK,
} from "kolu-common/config";
Expand Down Expand Up @@ -48,17 +49,25 @@ export function spawnPty(
onCwd?: (cwd: string) => void;
},
clipboard: { shimBinDir: string; clipboardDir: string },
tmux: { shimBinDir: string; tmuxEnv: string; paneId: string },
spawnCwd?: string,
): PtyHandle {
const env = cleanEnv();
const shell = env.SHELL ?? "/bin/sh";
const cwd = spawnCwd || env.HOME || "/";

// Inject clipboard shim dir into shell rc AFTER the user's rc —
// Inject shim dirs into shell rc AFTER the user's rc —
// NixOS rebuilds PATH during shell init, so env-level PATH gets lost.
const osc7 = osc7Init(shell, env.HOME, clipboard.shimBinDir);
const extraPath = [clipboard.shimBinDir, tmux.shimBinDir]
.filter(Boolean)
.join(":");
const osc7 = osc7Init(shell, env.HOME, extraPath);
Object.assign(env, osc7.env);
env.KOLU_CLIPBOARD_DIR = clipboard.clipboardDir;
// tmux compatibility: Claude Code detects multiplexer via $TMUX and uses $TMUX_PANE as self-identity
env.TMUX = tmux.tmuxEnv;
env.TMUX_PANE = tmux.paneId;
env.KOLU_PORT = process.env.KOLU_PORT ?? String(DEFAULT_PORT);

tlog.info({ shell, cwd }, "spawning pty");
const proc = pty.spawn(shell, osc7.args, {
Expand Down
19 changes: 19 additions & 0 deletions server/src/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createClipboardDir,
cleanupClipboardDir,
} from "./clipboard.ts";
import { getTmuxShimDir, allocatePaneIndex, tmuxEnvValue } from "./tmux-env.ts";
import {
createMetadata,
updateMetadata,
Expand All @@ -44,6 +45,8 @@ export interface TerminalProcess {
clipboardDir: string;
/** Cleanup function for all metadata providers. */
stopProviders: () => void;
/** Synthetic tmux pane index (%N in $TMUX_PANE). Monotonic, never reused. */
tmuxPaneIndex: number;
}

const terminals = new Map<TerminalId, TerminalProcess>();
Expand Down Expand Up @@ -129,6 +132,7 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo {
const tlog = log.child({ terminal: id });
const clipboardDir = createClipboardDir(id);

const paneIndex = allocatePaneIndex();
const handle = spawnPty(
tlog,
{
Expand Down Expand Up @@ -168,6 +172,11 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo {
},
},
{ shimBinDir: CLIPBOARD_SHIM_DIR, clipboardDir },
{
shimBinDir: getTmuxShimDir(),
tmuxEnv: tmuxEnvValue(),
paneId: `%${paneIndex}`,
},
cwd,
);

Expand All @@ -184,6 +193,7 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo {
activityHistory: [[Date.now(), true] as ActivitySample],
clipboardDir,
stopProviders: () => {},
tmuxPaneIndex: paneIndex,
};
// Start providers after entry is in the map (providers may emit immediately)
terminals.set(id, entry);
Expand All @@ -203,6 +213,15 @@ export function listTerminals(): TerminalInfo[] {
return list;
}

/** Terminal list with tmux pane indices — used by the tmux shim's /api/terminals endpoint. */
export function listTerminalsWithPaneIndex(): (TerminalInfo & {
tmuxPaneIndex: number;
})[] {
return [...terminals.values()]
.sort((a, b) => a.info.meta.sortOrder - b.info.meta.sortOrder)
.map((entry) => ({ ...entry.info, tmuxPaneIndex: entry.tmuxPaneIndex }));
}

export function getTerminal(id: TerminalId): TerminalProcess | undefined {
return terminals.get(id);
}
Expand Down
82 changes: 82 additions & 0 deletions server/src/tmux-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* tmux compatibility environment for PTY shells.
*
* Creates a temporary directory containing a `tmux` wrapper script
* that invokes the kolu-tmux shim. This directory is prepended to
* PTY shell PATH so Claude Code (and other AI tools) find `tmux`
* and auto-detect multiplexer support.
*
* Also manages per-terminal synthetic pane IDs ($TMUX_PANE) and
* the $TMUX env var that signals "inside tmux" to tools.
*/

import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";

const __dirname = dirname(fileURLToPath(import.meta.url));

/** Path to tmux-shim.ts (co-located with this module). */
const SHIM_SCRIPT = join(__dirname, "tmux-shim.ts");

/** Resolve absolute path to tsx at startup — PTY shells may not have it on PATH
* because NixOS shell init rebuilds PATH from scratch. */
function resolveTsxPath(): string {
try {
return execFileSync("which", ["tsx"], { encoding: "utf-8" }).trim();
} catch {
// Fallback: assume tsx is on PATH (production Nix wrapper guarantees it)
return "tsx";
}
}

/** Temp directory containing the `tmux` wrapper. Created once at startup. */
let shimBinDir: string | undefined;

/** Monotonically increasing pane index counter. Never reused — keeps IDs stable. */
let nextPaneIndex = 0;

/** Create the tmux shim bin directory. Call once at server startup. */
export function initTmuxShim(): void {
const dir = join(tmpdir(), `kolu-tmux-shim-${process.pid}`);
mkdirSync(dir, { recursive: true });

// Write a tiny wrapper script that delegates to tsx + tmux-shim.ts.
// Use absolute tsx path — PTY shells may not have it on PATH after shell init.
const tsxPath = resolveTsxPath();
const wrapper = join(dir, "tmux");
writeFileSync(
wrapper,
`#!/bin/sh\nexec "${tsxPath}" "${SHIM_SCRIPT}" "$@"\n`,
);
chmodSync(wrapper, 0o755);

shimBinDir = dir;
}

/** Get the tmux shim bin directory. Throws if initTmuxShim() hasn't been called. */
export function getTmuxShimDir(): string {
if (!shimBinDir) throw new Error("initTmuxShim() not called");
return shimBinDir;
}

/** Remove the temp directory on shutdown. */
export function cleanupTmuxShim(): void {
if (shimBinDir) {
rmSync(shimBinDir, { recursive: true, force: true });
shimBinDir = undefined;
}
}

/** Allocate a unique pane index for a new terminal. Returns the index (0, 1, 2, ...). */
export function allocatePaneIndex(): number {
return nextPaneIndex++;
}

/** Build the $TMUX value for PTY shells. Format matches tmux: <socket>,<pid>,0 */
export function tmuxEnvValue(): string {
const socketPath = join(tmpdir(), `kolu-tmux-${process.pid}`, "default");
return `${socketPath},${process.pid},0`;
}
Loading