import type { ResolvedTmuxBinary } from "./psmux-detect"; import { resolveGjcTmuxBinary } from "./psmux-detect"; export { assertGjcTmuxMutationAuthoritySync, bindGjcTmuxProviderAuthority, buildTmuxProviderCommand, hasGjcTmuxProviderAuthoritySync, type ProviderAuthority, type ProviderContext, persistGjcTmuxProviderAuthoritySync, readGjcTmuxProviderAuthoritySync, resolveGjcTmuxProviderContext, type TmuxProviderKind, } from "./tmux-provider-context"; export const GJC_DEFAULT_TMUX_SESSION = "gajae_code"; export const GJC_TMUX_SESSION_PREFIX = `${GJC_DEFAULT_TMUX_SESSION}_`; export const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND"; export const GJC_TMUX_ACTIVE_SESSION_ENV = "GJC_TMUX_ACTIVE_SESSION"; export const GJC_TMUX_PROFILE_ENV = "GJC_TMUX_PROFILE"; export const GJC_TMUX_MOUSE_ENV = "GJC_MOUSE"; export const GJC_TMUX_PROFILE_OPTION = "@gjc-profile"; export const GJC_TMUX_PROFILE_VALUE = "1"; export const GJC_TMUX_BRANCH_OPTION = "@gjc-branch"; export const GJC_TMUX_BRANCH_SLUG_OPTION = "@gjc-branch-slug"; export const GJC_TMUX_PROJECT_OPTION = "@gjc-project"; export const GJC_TMUX_SESSION_ID_OPTION = "@gjc-session-id"; export const GJC_TMUX_SESSION_STATE_FILE_OPTION = "@gjc-session-state-file"; export const GJC_TMUX_OWNER_GENERATION_OPTION = "@gjc-owner-generation"; export const GJC_TMUX_OWNER_SERVER_KEY_OPTION = "@gjc-owner-server-key"; export const GJC_TMUX_VERSION_OPTION = "@gjc-version"; export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE"; export interface GjcTmuxProfileCommand { description: string; args: string[]; } export interface TmuxCommandResult { exitCode: number | null; stdout?: string; stderr?: string; signalCode?: string | null; } export type TmuxCommandRunner = (args: string[]) => TmuxCommandResult; export function envDisabled(value: string | undefined): boolean { const normalized = value?.trim().toLowerCase(); return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no"; } /** * Resolve the tmux (or tmux-compatible multiplexer) command GJC should invoke. * * This is the shared entry point used by every GJC code path that needs to talk * to a multiplexer: `gjc --tmux` planning and `gjc session ...`, * the lifecycle controller, and the harness resident owner. Routing all of * them through the same resolver means a single `GJC_TMUX_COMMAND` override or * a single Windows psmux / pmux detection wins for the whole process — the * failure mode where `gjc --tmux` creates a psmux-backed session and then * `gjc session status` fails because it queries literal `tmux` is closed off. * * Explicit `GJC_TMUX_COMMAND` overrides are honored on * every platform. On native Windows without an override the resolver walks * `psmux`, then `pmux`, then `tmux` and uses the first binary present on PATH. * On POSIX the resolver returns `tmux` (the historical default) and only * falls through to the platform-aware walker if the caller opts in. */ export function resolveGjcTmuxCommand( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): string { return resolveGjcTmuxBinary({ env, platform }).command; } export type { PsmuxProbe, ResolvedTmuxBinary, ResolveGjcTmuxBinaryOptions } from "./psmux-detect"; export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveGjcTmuxBinary } from "./psmux-detect"; /** * Build the exact-session target for tmux *option* commands * (`show-options` / `set-option`) and `display-message -t`. * * Session-scoped commands such as `kill-session` / `attach-session` resolve a * bare exact target (`=NAME`), but tmux 3.6a refuses to resolve a bare `=NAME` * for option/display commands. Appending the empty window separator (`=NAME:`) * keeps the exact-session match while giving tmux the window-qualified target * those commands require. See gajae-code#580. */ export function buildGjcTmuxExactOptionTarget( sessionName: string, opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {}, ): string { const binary = opts.binary ?? resolveGjcTmuxBinary({ env: opts.env, platform: opts.platform }); // psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option // commands ("no server running on session '=NAME'"); bare `NAME` and // window-qualified `NAME:` both work. tmux 3.6a needs the // window-qualified `=NAME:` to resolve the session for option // commands (gajae-code#580). if (binary.isPsmux) return sessionName; return `=${sessionName}:`; } /** * Build the exact-session target for tmux *session-scoped* commands such as * `attach-session` and `kill-session`. Native tmux accepts `=NAME` for an * exact session match, but Windows psmux 3.3.x rejects that target form for * session commands even though the bare `NAME` resolves. Keep native tmux on * exact targets and intentionally use the bare session name for psmux. */ export function buildGjcTmuxExactSessionTarget( sessionName: string, opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {}, ): string { const binary = opts.binary ?? resolveGjcTmuxBinary({ env: opts.env, platform: opts.platform }); if (binary.isPsmux) return sessionName; return `=${sessionName}`; } export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged"; export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string { return ( `the active multiplexer "${tmuxCommand}" lists this session but did not return GJC's ${GJC_TMUX_PROFILE_OPTION} ownership tag; ` + "GJC-managed sessions require a tmux provider that round-trips tmux user options. " + "On Windows psmux, GJC persists a ProviderAuthority that binds the exact executable identity and an isolated `-L ` server namespace for the owner generation. " + "Recover through GJC so it reuses that persisted authority; do not retry against ambient tmux/psmux or a raw `-L` namespace. " + "GJC_TMUX_COMMAND is a binary override, not a shell command line." ); } export function buildGjcTmuxUntaggedSessionError(sessionName: string, tmuxCommand: string): string { return `${GJC_TMUX_UNTAGGED_REASON}:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`; } export function sanitizeTmuxToken(value: string): string { return ( value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") || "default" ); } export function buildGjcTmuxSessionSlug(value: string): string { return sanitizeTmuxToken(value); } function randomTmuxSessionSuffix(): string { return Math.random().toString(36).slice(2, 10); } export function buildGjcTmuxSessionName( env: NodeJS.ProcessEnv = process.env, context: { branch?: string | null; now?: number; id?: string } = {}, ): string { const explicit = env.GJC_TMUX_SESSION?.trim(); if (explicit) return explicit; const timestamp = (context.now ?? Date.now()).toString(36); const id = context.id ?? randomTmuxSessionSuffix(); const branchSlug = context.branch ? `${buildGjcTmuxSessionSlug(context.branch)}_` : ""; return `${GJC_TMUX_SESSION_PREFIX}${branchSlug}${timestamp}_${id}`; } export function buildGjcTmuxRequiredProfileCommands( target: string, metadata: { branch?: string | null; branchSlug?: string | null; project?: string | null; sessionId?: string | null; sessionStateFile?: string | null; ownerGeneration?: string | null; ownerServerKey?: string | null; version?: string | null; } = {}, ): GjcTmuxProfileCommand[] { const commands: GjcTmuxProfileCommand[] = []; if (metadata.branch) commands.push({ description: "record GJC branch identity", args: ["set-option", "-t", target, GJC_TMUX_BRANCH_OPTION, metadata.branch], }); if (metadata.branchSlug) commands.push({ description: "record GJC branch slug", args: ["set-option", "-t", target, GJC_TMUX_BRANCH_SLUG_OPTION, metadata.branchSlug], }); if (metadata.project) commands.push({ description: "record GJC project identity", args: ["set-option", "-t", target, GJC_TMUX_PROJECT_OPTION, metadata.project], }); if (metadata.sessionId) commands.push({ description: "record GJC session identity", args: ["set-option", "-t", target, GJC_TMUX_SESSION_ID_OPTION, metadata.sessionId], }); if (metadata.sessionStateFile) commands.push({ description: "record GJC session state marker", args: ["set-option", "-t", target, GJC_TMUX_SESSION_STATE_FILE_OPTION, metadata.sessionStateFile], }); if (metadata.ownerGeneration) commands.push({ description: "record GJC owner generation", args: ["set-option", "-t", target, GJC_TMUX_OWNER_GENERATION_OPTION, metadata.ownerGeneration], }); if (metadata.ownerServerKey) commands.push({ description: "record GJC owner server key", args: ["set-option", "-t", target, GJC_TMUX_OWNER_SERVER_KEY_OPTION, metadata.ownerServerKey], }); if (metadata.version) commands.push({ description: "record GJC version identity", args: ["set-option", "-t", target, GJC_TMUX_VERSION_OPTION, metadata.version], }); commands.push({ description: "mark GJC tmux ownership", args: ["set-option", "-t", target, GJC_TMUX_PROFILE_OPTION, GJC_TMUX_PROFILE_VALUE], }); return commands; } /** * Keys whose set-option / set-window-option round-trip is unreliable on psmux * 3.3.0. psmux does not support the tmux `set-window-option` command at all * (it reports "unknown command: set-window-option") and silently drops several * `set-option` keys. The list lives here so every code path that tags a tmux * session (gjc --tmux planning, gjc session create) * applies the same filter. */ const PSMUX_UNSUPPORTED_PROFILE_KEYS = new Set(["mouse", "set-clipboard", "mode-style"]); export function buildGjcTmuxProfileCommands( target: string, env: NodeJS.ProcessEnv = process.env, metadata: { branch?: string | null; branchSlug?: string | null; project?: string | null; sessionId?: string | null; sessionStateFile?: string | null; ownerGeneration?: string | null; ownerServerKey?: string | null; version?: string | null; } = {}, opts: { platform?: NodeJS.Platform; tmuxCommand?: string } = {}, ): GjcTmuxProfileCommand[] { const commands = buildGjcTmuxRequiredProfileCommands(target, metadata); if (envDisabled(env[GJC_TMUX_PROFILE_ENV])) return commands; commands.push( { description: "enable tmux clipboard integration", args: ["set-option", "-t", target, "set-clipboard", "on"] }, { description: "make copy-mode selection readable", args: ["set-window-option", "-t", target, "mode-style", "fg=colour231,bg=colour60"], }, ); if (!envDisabled(env[GJC_TMUX_MOUSE_ENV])) commands.unshift({ description: "enable tmux mouse scrolling", args: ["set-option", "-t", target, "mouse", "on"], }); // psmux does not implement set-window-option and historically drops // mouse / set-clipboard / mode-style. Filter the UX profile commands // centrally so every code path that tags a session (gjc --tmux planning, // gjc session create) drops the same set. The // GJC_PSMUX_PROFILE_FORCE override lets the operator opt back in when // running on a psmux build that has caught up. The ownership-tag // round-trip (set-option @gjc-*) is never filtered, since gjc session / // tmux-backed sessions rely on it. // The filter is opt-in: callers that explicitly pass `opts.tmuxCommand` // name a psmux-class multiplexer (psmux / pmux) when they want the UX // profile filtered. Auto-detect on Windows hosts where psmux happens // to be on PATH would silently change the test output for every caller // that does not pin the multiplexer, so we require the caller to opt // in by naming the multiplexer. GJC_PSMUX_PROFILE_FORCE re-enables // the UX profile commands when a psmux build catches up. const tmuxName = (opts.tmuxCommand ?? "").toLowerCase(); const isPsmuxClass = tmuxName === "psmux" || tmuxName === "pmux" || tmuxName.endsWith("/psmux") || tmuxName.endsWith("/pmux") || tmuxName.endsWith("\\psmux") || tmuxName.endsWith("\\pmux"); const forcePsmuxProfile = env[GJC_PSMUX_PROFILE_FORCE_ENV] === "true" || env[GJC_PSMUX_PROFILE_FORCE_ENV] === "1"; const dropUx = isPsmuxClass && !forcePsmuxProfile; if (dropUx) { return commands.filter(command => { const flag = command.args[0]; const key = command.args[command.args.length - 2]; return !( PSMUX_UNSUPPORTED_PROFILE_KEYS.has(String(key)) && (flag === "set-option" || flag === "set-window-option") ); }); } return commands; } export function normalizeTmuxCreatedAt(raw: string): string { const seconds = Number.parseInt(raw, 10); if (!Number.isFinite(seconds) || seconds <= 0) return raw; return new Date(seconds * 1000).toISOString(); }