@vibecontrols/vibe-plugin-session-tmux 2026.608.1 → 2026.608.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +25 -0
- package/dist/index.js +4 -4
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,31 @@ interface SessionProviderCapabilities {
|
|
|
156
156
|
};
|
|
157
157
|
platform: string[];
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Apply the terminal interaction defaults to a freshly created or adopted
|
|
161
|
+
* tmux session. Centralised (and exported for tests) so every create path
|
|
162
|
+
* — auto-named, explicit-name, adopt — stays in sync instead of drifting.
|
|
163
|
+
*
|
|
164
|
+
* Mouse mode is deliberately left **off**. With `mouse on`, the browser
|
|
165
|
+
* terminal (ttyd/xterm.js) hands every drag to tmux, which drops the pane
|
|
166
|
+
* into copy-mode — the user sees the "SCROLL MODE — press Esc or q" banner,
|
|
167
|
+
* the drag does not visibly highlight, and a mouse copy/paste is impossible
|
|
168
|
+
* without holding Shift. With `mouse off`, xterm.js owns the mouse at the
|
|
169
|
+
* shell prompt: a plain click-drag does a native selection that copies with
|
|
170
|
+
* Cmd/Ctrl+C and pastes with the browser — the iTerm2-style behaviour users
|
|
171
|
+
* expect. tmux still forwards the mouse to a full-screen pane app that
|
|
172
|
+
* requests it (claude, vim, htop, less), so those keep their own click and
|
|
173
|
+
* wheel scroll. Users who specifically want tmux's own wheel-scroll-into-
|
|
174
|
+
* history back can flip it per session via `toggleMouse()`.
|
|
175
|
+
*
|
|
176
|
+
* The OSC-52 clipboard bridge, the copy-mode drag-end bindings, and the
|
|
177
|
+
* scroll-mode banner only take effect once mouse mode is toggled on, but we
|
|
178
|
+
* configure them here (idempotently) so that path stays ergonomic too.
|
|
179
|
+
*
|
|
180
|
+
* `exec` is injectable so tests can capture the issued commands; it defaults
|
|
181
|
+
* to the real (non-throwing) tmux runner.
|
|
182
|
+
*/
|
|
183
|
+
export declare function applyTmuxSessionDefaults(sessionName: string, exec?: (args: string[]) => boolean): void;
|
|
159
184
|
/**
|
|
160
185
|
* Plugin contract V2 factory. The agent calls this once per profile at
|
|
161
186
|
* load time. Lifecycle hooks and the VibePlugin object are constructed
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
import{Elysia}from"elysia";function createLifecycleHooks(spec){let{name,onInit,onShutdown,onNuke,telemetryEventName,skipPlatforms}=spec;return{onServerStart:async(_app,hostServices)=>{if(skipPlatforms&&skipPlatforms.includes(process.platform)){process.stderr.write(`[${name}] skipping init on unsupported platform '${process.platform}'
|
|
3
|
-
`);return}if(onInit)await onInit(hostServices);if(telemetryEventName)hostServices.telemetry?.emit(telemetryEventName,{plugin:name})},onServerStop:async(hostServices)=>{if(onShutdown)await onShutdown(hostServices)},onNuke:async(hostServices,ctx)=>{if(onNuke)return onNuke(hostServices,ctx)}}}var TypedStore=class{constructor(storage,namespace,key,logger,pluginName="plugin"){this.storage=storage,this.namespace=namespace,this.key=key,this.logger=logger,this.pluginName=pluginName}storage;namespace;key;logger;pluginName;async get(){let raw=await this.storage.get(this.namespace,this.key);if(raw===null||raw===void 0)return null;if(typeof raw!=="string")return raw;try{return JSON.parse(raw)}catch(err){return this.logger?.error?.(this.pluginName,"TypedStore: corrupt JSON",{namespace:this.namespace,key:this.key,message:err instanceof Error?err.message:String(err)}),null}}async set(value){await this.storage.set(this.namespace,this.key,JSON.stringify(value))}async delete(){return this.storage.delete(this.namespace,this.key)}};import{createServer}from"net";function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}function isProcessAlive(pid){try{return process.kill(pid,0),!0}catch{return!1}}async function gracefulKill(pid,timeoutMs=3000,logger){if(!isProcessAlive(pid))return;try{process.kill(pid,"SIGTERM")}catch(err){logger?.warn?.("subprocess","SIGTERM failed",{pid,message:err instanceof Error?err.message:String(err)});return}let pollMs=50,elapsed=0;while(elapsed<timeoutMs)if(await sleep(pollMs),elapsed+=pollMs,!isProcessAlive(pid))return;try{process.kill(pid,"SIGKILL")}catch(err){logger?.warn?.("subprocess","SIGKILL failed",{pid,message:err instanceof Error?err.message:String(err)})}}async function findAvailablePort(start,range=200){for(let i=0;i<range;i++){let port=start+i;if(await isPortFree(port))return port}throw Error(`findAvailablePort: no port free in [${start}, ${start+range-1}]`)}function isPortFree(port){return new Promise((resolve)=>{let server=createServer();server.once("error",()=>{resolve(!1)}),server.once("listening",()=>{server.close(()=>resolve(!0))});try{server.listen({port,host:"0.0.0.0"})}catch{resolve(!1)}})}var BoundLogger=class{constructor(logger,source){this.logger=logger,this.source=source}logger;source;info(message,meta){this.logger?.info?.(this.source,message,meta)}warn(message,meta){this.logger?.warn?.(this.source,message,meta)}error(message,meta){this.logger?.error?.(this.source,message,meta)}debug(message,meta){this.logger?.debug?.(this.source,message,meta)}};var TelemetryEmitter=class{constructor(pluginName,pluginVersion,hostServices){this.pluginName=pluginName,this.pluginVersion=pluginVersion,this.hostServices=hostServices}pluginName;pluginVersion;hostServices;emit(eventName,payload){let target=this.hostServices?.telemetry;if(!target)return;target.emit(eventName,{plugin:this.pluginName,version:this.pluginVersion,timestamp:new Date().toISOString(),...payload??{}})}emitReady(context){this.emit(`${this.pluginName}.ready`,context)}emitError(error,context){this.emit(`${this.pluginName}.error`,{message:error.message,...context??{}})}emitEvent(type,payload){this.emit(type,payload)}};var CONTEXT_PROVIDER_TYPE="context",REGISTRY_KEY=Symbol.for("@vibecontrols/plugin-sdk:contextProviders@1");function getRegistry(){let slots=globalThis,existing=slots[REGISTRY_KEY];if(existing)return existing;let created=new Map;return slots[REGISTRY_KEY]=created,created}function registerContextProvider(provider,hostServices){if(!provider||typeof provider.name!=="string"||provider.name.length===0)throw Error("ContextProvider.name is required");if(typeof provider.getContext!=="function")throw Error("ContextProvider.getContext must be a function");getRegistry().set(provider.name,provider),hostServices?.serviceRegistry?.registerProvider?.(CONTEXT_PROVIDER_TYPE,provider,provider.name)}function listContextProviders(){return Array.from(getRegistry().values())}function getContextProvider(name){return getRegistry().get(name)}var ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;registerContextProvider(provider){registerContextProvider(provider,this.hostServices)}listContextProviders(){return listContextProviders()}getContextProvider(name){return getContextProvider(name)}getServiceRegistry(){return this.hostServices?.serviceRegistry}registerProvider(type,name,provider){this.hostServices?.serviceRegistry?.registerProvider?.(type,provider,name)}getProvider(type,name){let reg=this.hostServices?.serviceRegistry;if(!reg)return;if(name===void 0)return reg.getProvider?.(type);if(reg.getProviderByName)return reg.getProviderByName(type,name);if(!reg.listProvidersForType)return;if(!(reg.listProvidersForType(type)??[]).some((entry)=>typeof entry==="string"?entry===name:entry.pluginName===name))return;return reg.getProvider?.(type)}listProviders(type){return(this.hostServices?.serviceRegistry?.listProvidersForType?.(type)??[]).map((entry)=>typeof entry==="string"?entry:entry.pluginName)}withCliContribution(contribution){let contributors=this.hostServices?.cliContributors;if(!contributors)return;for(let section of contribution.statusSections??[])contributors.addStatusSection?.(section);for(let check of contribution.doctorChecks??[])contributors.addDoctorCheck?.(check)}};import{spawn}from"child_process";import{createHash}from"crypto";import{existsSync,promises,createWriteStream}from"fs";import{homedir,tmpdir}from"os";import*as path from"path";import{Readable}from"stream";import{pipeline}from"stream/promises";function currentPlatform(){let arch=process.arch==="x64"?"x64":"arm64";return`${process.platform==="win32"?"win32":process.platform==="darwin"?"darwin":"linux"}-${arch}`}function toolsCacheRoot(){return path.join(homedir(),".boff","vibecontrols","tools")}function binFileName(name,binaryName){let base=binaryName??name;if(process.platform==="win32"&&!base.toLowerCase().endsWith(".exe"))return`${base}.exe`;return base}function cachedBinaryPath(name,binaryName,cacheRoot){return path.join(cacheRoot??toolsCacheRoot(),name,binFileName(name,binaryName))}function resolveBinary(name,binaryName,cacheRoot){let cached=cachedBinaryPath(name,binaryName,cacheRoot);if(existsSync(cached))return cached;try{let onPath=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which(binaryName??name,{PATH:process.env.PATH}):null;if(onPath)return onPath}catch{}return null}async function versionOk(binaryPath,spec){if(!spec.versionMatcher)return!0;return new Promise((resolve)=>{let child=spawn(binaryPath,spec.versionArgs??["--version"],{stdio:["ignore","pipe","pipe"]}),out="";child.stdout.on("data",(b)=>out+=b.toString()),child.stderr.on("data",(b)=>out+=b.toString()),child.on("close",()=>{try{resolve(new RegExp(spec.versionMatcher).test(out))}catch{resolve(!1)}}),child.on("error",()=>resolve(!1))})}async function installBinary(spec){let platform=spec.platform??currentPlatform(),binaryName=binFileName(spec.name,spec.binaryName),cached=cachedBinaryPath(spec.name,spec.binaryName,spec.cacheRoot);if(existsSync(cached)&&await versionOk(cached,spec))return cached;try{let onPath=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which(spec.binaryName??spec.name,{PATH:process.env.PATH}):null;if(onPath&&await versionOk(onPath,spec))return spec.log?.info?.(`[install] using PATH binary ${spec.name} (${onPath})`),onPath}catch{}let download=spec.downloads[platform];if(!download)throw Error(`[install] no download manifest entry for ${spec.name} on ${platform}`);return spec.log?.info?.(`[install] downloading ${spec.name} for ${platform}`),await promises.mkdir(path.dirname(cached),{recursive:!0}),await downloadAndInstall(download,cached,binaryName,spec.name),spec.log?.info?.(`[install] installed ${spec.name} \u2192 ${cached}`),cached}async function downloadAndInstall(d,destBinary,binaryName,toolName){let destDir=path.dirname(destBinary),tmp=path.join(tmpdir(),`vibe-install-${toolName}-${process.pid}`);await promises.mkdir(tmp,{recursive:!0});let archivePath=path.join(tmp,"artifact");try{let res=await fetch(d.url,{redirect:"follow"});if(!res.ok)throw Error(`download failed (${res.status}) for ${d.url}`);if(!res.body)throw Error(`empty response body for ${d.url}`);if(await pipeline(Readable.fromWeb(res.body),createWriteStream(archivePath)),d.sha256){let actual=createHash("sha256").update(await promises.readFile(archivePath)).digest("hex");if(actual!==d.sha256)throw Error(`sha256 mismatch for ${toolName}: expected ${d.sha256}, got ${actual}`)}let archiveType=d.archive??(d.url.endsWith(".tar.gz")||d.url.endsWith(".tgz")?"tar.gz":d.url.endsWith(".zip")?"zip":"raw");if(archiveType==="raw")await promises.copyFile(archivePath,destBinary);else{if(archiveType==="tar.gz")await runProcess("tar",["-xzf",archivePath,"-C",tmp]);else await extractZip(archivePath,tmp);let inner=d.binaryWithinArchive??binaryName;await promises.copyFile(path.join(tmp,inner),destBinary)}if(process.platform!=="win32")await promises.chmod(destBinary,493)}finally{await promises.rm(tmp,{recursive:!0,force:!0})}}async function extractZip(archive,dest){try{await runProcess("unzip",["-q","-o",archive,"-d",dest]);return}catch(err){if(process.platform!=="win32")throw err;await runProcess("tar",["-xf",archive,"-C",dest])}}async function runProcess(cmd,args){return new Promise((resolve,reject)=>{let child=spawn(cmd,args,{stdio:["ignore","ignore","pipe"]}),err="";child.stderr.on("data",(b)=>err+=b.toString()),child.on("close",(code)=>code===0?resolve():reject(Error(`${cmd} exit ${code}: ${err}`))),child.on("error",reject)})}var CLOUDFLARED_BASE="https://github.com/cloudflare/cloudflared/releases/latest/download",CLOUDFLARED_DOWNLOADS={"linux-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-linux-amd64`,archive:"raw"},"linux-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-linux-arm64`,archive:"raw"},"darwin-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-darwin-amd64.tgz`,archive:"tar.gz",binaryWithinArchive:"cloudflared"},"darwin-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-darwin-arm64.tgz`,archive:"tar.gz",binaryWithinArchive:"cloudflared"},"win32-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-windows-amd64.exe`,archive:"raw"},"win32-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-windows-arm64.exe`,archive:"raw"}};var TTYD_DOWNLOADS={"linux-x64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64",archive:"raw"},"linux-arm64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.aarch64",archive:"raw"},"win32-x64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.win32.exe",archive:"raw"}};async function ensureTtydBinary(log){let existing=resolveBinary("ttyd");if(existing)return existing;log?.info("ttyd not found \u2014 attempting auto-install");try{let installed=await installBinary({name:"ttyd",downloads:TTYD_DOWNLOADS,versionMatcher:"ttyd version",log:{info:(msg)=>log?.info(msg),warn:(msg)=>log?.warn(msg),error:(msg)=>log?.error(msg)}});return log?.info("ttyd auto-installed",{path:installed}),installed}catch(err){let cause=err instanceof Error?err.message:String(err);throw Error(`ttyd is not available and auto-install failed. ${ttydManualInstall()}. Cause: ${cause}`,{cause:err})}}var TTYD_HOST="127.0.0.1",TTYD_WS_PATH="/ws",TTYD_SUBPROTOCOLS=["tty"],PLUGIN_NAME="session-tmux",PLUGIN_VERSION="2.3.0",PROVIDER_NAME="session-tmux",STORAGE_NAMESPACE="session-tmux",STORAGE_KEY_SESSIONS="sessions",STORAGE_KEY_TERMINALS="terminals",TTYD_BASE_PORT=7681,TTYD_PORT_RANGE=200;function generateId(){let bytes=new Uint8Array(4);return crypto.getRandomValues(bytes),[...bytes].map((b)=>b.toString(16).padStart(2,"0")).join("")}function tmuxExec(args){let result=Bun.spawnSync(["tmux",...args],{stdout:"pipe",stderr:"pipe",timeout:1e4});if(result.exitCode!==0){let stderr=result.stderr.toString().trim();throw Error(`tmux exited with code ${result.exitCode}: ${stderr}`)}return result.stdout.toString("utf-8").trimEnd()}function tmuxExecSilent(args){try{return Bun.spawnSync(["tmux",...args],{stdout:"pipe",stderr:"pipe",timeout:1e4}).exitCode===0}catch{return!1}}function nowISO(){return new Date().toISOString()}class TmuxSessionProvider{name=PROVIDER_NAME;log=new BoundLogger(void 0,PLUGIN_NAME);sessionsStore=null;terminalsStore=null;ttydPids=new Map;ttydPorts=new Map;async init(services){if(this.log=new BoundLogger(services.logger,PLUGIN_NAME),services.storage)this.sessionsStore=new TypedStore(services.storage,STORAGE_NAMESPACE,STORAGE_KEY_SESSIONS,services.logger,PLUGIN_NAME),this.terminalsStore=new TypedStore(services.storage,STORAGE_NAMESPACE,STORAGE_KEY_TERMINALS,services.logger,PLUGIN_NAME);this.log.info("TmuxSessionProvider initialising",{provider:this.name});try{let version=tmuxExec(["-V"]);this.log.info("tmux detected",{version})}catch{this.log.error("tmux is not installed or not in PATH \u2014 session provider will not function")}await this.reconcileSessions(),this.log.info("TmuxSessionProvider ready")}async shutdown(context){if(context?.reason==="reload"){this.log.info("Hot-reload: preserving tmux sessions and ttyd processes"),await this.persistTerminals(),this.ttydPids.clear(),this.ttydPorts.clear();return}this.log.info("TmuxSessionProvider shutting down \u2014 stopping terminals");let stopPromises=[];for(let[sessionId]of this.ttydPids)stopPromises.push(this.stopTerminal(sessionId));await Promise.allSettled(stopPromises);try{await this.terminalsStore?.delete()}catch{}this.log.info("TmuxSessionProvider shutdown complete")}async nuke(){this.log.info("TmuxSessionProvider nuke \u2014 reaping spawned terminals");let tracked=[...this.ttydPids.keys()],terminalsReaped=0;await Promise.allSettled(tracked.map(async(sessionId)=>{let pid=this.ttydPids.get(sessionId);if(await this.stopTerminal(sessionId),pid!==void 0)terminalsReaped++}));try{let raw=Bun.spawnSync(["pgrep","-a","ttyd"],{stdout:"pipe",stderr:"pipe",timeout:5000}).stdout.toString().trim();if(raw){let stillTracked=new Set(this.ttydPids.values());for(let line of raw.split(`
|
|
4
|
-
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid||stillTracked.has(pid))continue;let tIdx=parts.indexOf("-t"),tmuxTarget=tIdx!==-1?parts[tIdx+1]:null;if(!tmuxTarget||!tmuxTarget.startsWith("vibe-"))continue;if(!isProcessAlive(pid))continue;this.log.info("Reaping untracked ttyd attached to vibe session",{pid,tmuxTarget}),await gracefulKill(pid),terminalsReaped++}}}catch{}this.ttydPids.clear(),this.ttydPorts.clear();try{await this.terminalsStore?.delete()}catch{}try{await this.sessionsStore?.delete()}catch{}return this.log.info("TmuxSessionProvider nuke complete",{terminalsReaped}),{terminalsReaped}}async create(config){if(config.externalName){let target=config.externalName;if(target.length===0||target.length>128||/[\0\r\n:.\s]/.test(target))throw Error(`Invalid externalName: ${target}`);if(tmuxExecSilent(["has-session","-t",target]))return this.log.info("create(): attaching to existing tmux session",{target}),this.adopt(target,config.name);let now2=nowISO(),args=["new-session","-d","-s",target];if(config.workingDirectory)args.push("-c",config.workingDirectory);if(config.size)args.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args.push(config.shell);if(tmuxExec(args),config.environment)for(let[k,v]of Object.entries(config.environment))tmuxExecSilent(["set-environment","-t",target,k,v]);if(tmuxExecSilent(["set","-t",target,"mouse","on"]),config.command)tmuxExecSilent(["send-keys","-t",target,config.command,"Enter"]);let id2=target.startsWith("vibe-")?target.slice(5)||target:target,pid2=this.getSessionPanePid(target),info2={id:id2,name:config.name,status:"active",provider:this.name,command:config.command,workingDirectory:config.workingDirectory,pid:pid2??void 0,projectId:config.projectId,createdAt:now2,updatedAt:now2,metadata:{tmuxSessionName:target,shell:config.shell,size:config.size,createdWithExplicitName:!0}};return await this.saveSession(info2),this.log.info("Tmux session created with explicit name",{id:id2,target}),info2}let id=config.id||generateId(),sessionName=`vibe-${id.substring(0,8)}`,now=nowISO();this.log.info("Creating tmux session",{id,name:config.name,sessionName});let alreadyRunning=tmuxExecSilent(["has-session","-t",sessionName]);if(alreadyRunning)this.log.info("Reusing existing tmux session on resume",{id,sessionName});else{let args=["new-session","-d","-s",sessionName];if(config.workingDirectory)args.push("-c",config.workingDirectory);if(config.size)args.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args.push(config.shell);try{tmuxExec(args)}catch(err){throw this.log.error("Failed to create tmux session",{id,error:String(err)}),Error(`Failed to create tmux session: ${err}`,{cause:err})}}if(config.environment)for(let[key,value]of Object.entries(config.environment))tmuxExecSilent(["set-environment","-t",sessionName,key,value]);if(tmuxExecSilent(["set","-t",sessionName,"mouse","on"]),tmuxExecSilent(["set","-t",sessionName,"set-clipboard","on"]),tmuxExecSilent(["bind-key","-T","copy-mode","MouseDragEnd1Pane","send-keys","-X","copy-pipe-and-cancel"]),tmuxExecSilent(["bind-key","-T","copy-mode-vi","MouseDragEnd1Pane","send-keys","-X","copy-pipe-and-cancel"]),tmuxExecSilent(["set","-t",sessionName,"pane-border-format"," #[align=centre]#[bg=colour208,fg=black,bold] \u2B06 SCROLL MODE \u2014 press Esc or q to resume typing \u2B06 #[default]"]),tmuxExecSilent(["set-hook","-t",sessionName,"pane-mode-changed",'if -F "#{pane_in_mode}" "set-option -w pane-border-status top" "set-option -w pane-border-status off"']),config.command&&!alreadyRunning)tmuxExecSilent(["send-keys","-t",sessionName,config.command,"Enter"]);let pid=this.getSessionPanePid(sessionName),info={id,name:config.name,status:"active",provider:this.name,command:config.command,workingDirectory:config.workingDirectory,pid:pid??void 0,projectId:config.projectId,createdAt:now,updatedAt:now,metadata:{tmuxSessionName:sessionName,shell:config.shell,size:config.size}};return await this.saveSession(info),this.log.info("Tmux session created",{id,sessionName,pid}),info}async terminate(sessionId){let session=await this.getInfo(sessionId);if(!session){this.log.warn("Terminate called for unknown session",{sessionId});return}let tmuxName=this.getTmuxName(session);if(this.log.info("Terminating tmux session",{sessionId,tmuxName}),this.ttydPids.has(sessionId))await this.stopTerminal(sessionId);tmuxExecSilent(["kill-session","-t",tmuxName]),session.status="terminated",session.updatedAt=nowISO(),session.terminal=void 0,await this.saveSession(session),this.log.info("Tmux session terminated",{sessionId})}async getInfo(sessionId){let session=(await this.loadSessions()).find((s)=>s.id===sessionId)??null;if(session){let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(!exists&&session.status==="active")session.status="inactive",session.updatedAt=nowISO(),await this.saveSession(session);else if(exists&&session.status==="inactive")session.status="active",session.updatedAt=nowISO(),await this.saveSession(session);let termInfo=this.getRunningTerminalInfo(sessionId);if(termInfo)session.terminal=termInfo}return session}async list(){let sessions=await this.loadSessions();for(let session of sessions){if(session.status==="terminated")continue;let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(!exists&&session.status==="active")session.status="inactive",session.updatedAt=nowISO();else if(exists&&session.status!=="active")session.status="active",session.updatedAt=nowISO();let termInfo=this.getRunningTerminalInfo(session.id);if(termInfo)session.terminal=termInfo}return await this.saveSessions(sessions),sessions}async sendCommand(sessionId,command){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending command",{sessionId,command});try{tmuxExec(["send-keys","-t",tmuxName,command,"Enter"])}catch(err){throw this.log.error("Failed to send command",{sessionId,error:String(err)}),Error(`Failed to send command to session ${sessionId}: ${err}`,{cause:err})}}async sendKeys(sessionId,keys){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending keys",{sessionId,keys});try{tmuxExec(["send-keys","-t",tmuxName,keys])}catch(err){throw this.log.error("Failed to send keys",{sessionId,error:String(err)}),Error(`Failed to send keys to session ${sessionId}: ${err}`,{cause:err})}}async sendInterrupt(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending interrupt (C-c)",{sessionId});try{tmuxExec(["send-keys","-t",tmuxName,"C-c"])}catch(err){throw this.log.error("Failed to send interrupt",{sessionId,error:String(err)}),Error(`Failed to send interrupt to session ${sessionId}: ${err}`,{cause:err})}}async captureOutput(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Capturing output",{sessionId});try{return tmuxExec(["capture-pane","-t",tmuxName,"-p"])}catch(err){throw this.log.error("Failed to capture output",{sessionId,error:String(err)}),Error(`Failed to capture output from session ${sessionId}: ${err}`,{cause:err})}}async rename(sessionId,newName){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.info("Renaming session",{sessionId,from:session.name,to:newName});try{session.name=newName,session.updatedAt=nowISO(),await this.saveSession(session)}catch(err){throw this.log.error("Failed to rename session",{sessionId,error:String(err)}),Error(`Failed to rename session ${sessionId}: ${err}`,{cause:err})}let newTmuxName=`vibe-${session.id}`;tmuxExecSilent(["rename-session","-t",tmuxName,newTmuxName])}async toggleMouse(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session),mouseOn;try{mouseOn=tmuxExec(["show-options","-t",tmuxName,"-v","mouse"]).trim()==="on"}catch{mouseOn=!1}let newState=mouseOn?"off":"on";return tmuxExecSilent(["set","-t",tmuxName,"mouse",newState]),this.log.debug("Toggled mouse",{sessionId,mouse:newState}),newState==="on"}async getTerminationStatus(sessionId){let session=(await this.loadSessions()).find((s)=>s.id===sessionId);if(!session)return{terminated:!0,exists:!1};let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);return{terminated:session.status==="terminated"||!exists,exists}}async getTerminalInfo(sessionId){return this.getRunningTerminalInfo(sessionId)}async startTerminal(sessionId,port){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session),existing=this.getRunningTerminalInfo(sessionId);if(existing)return this.log.debug("Terminal already running",{sessionId,...existing}),existing;let inUsePorts=[...this.ttydPorts.values()],scanStart=inUsePorts.length?Math.max(TTYD_BASE_PORT,Math.max(...inUsePorts)+1):TTYD_BASE_PORT,assignedPort=port??await findAvailablePort(scanStart,TTYD_PORT_RANGE);this.log.info("Starting ttyd terminal",{sessionId,tmuxName,port:assignedPort});let ttydEnv={...process.env};ttydEnv.VIBECONTROLS_PROVIDER="tmux";let otherProviderVars={wezterm:["WEZTERM_PANE","WEZTERM_UNIX_SOCKET"],screen:["STY","WINDOW"],zellij:["ZELLIJ","ZELLIJ_SESSION_NAME","ZELLIJ_PANE_ID"]};for(let vars of Object.values(otherProviderVars))for(let v of vars)if(v in ttydEnv)delete ttydEnv[v];let ttydCmd=await ensureTtydBinary(this.log),child=Bun.spawn([ttydCmd,"-t","fontSize=14","-t",'theme={"background":"#1e1e1e","foreground":"#cccccc"}',"--writable","--port",String(assignedPort),"tmux","attach","-t",tmuxName],{stdout:"ignore",stderr:"pipe",stdin:"ignore",env:ttydEnv});if(!child.pid)throw Error("Failed to start ttyd \u2014 no PID returned");let ttydStderr="",stderrStream=child.stderr;if(stderrStream&&typeof stderrStream.getReader==="function")(async()=>{try{let reader=stderrStream.getReader(),dec=new TextDecoder;for(;;){let{done,value}=await reader.read();if(done)break;if(ttydStderr.length<4096)ttydStderr+=dec.decode(value,{stream:!0})}}catch{}})();if(await sleep(500),child.exitCode!==null||!isProcessAlive(child.pid)){let detail=ttydStderr.trim().slice(0,300)||"no stderr captured";throw this.log.error("ttyd exited immediately after launch",{sessionId,tmuxName,port:assignedPort,exitCode:child.exitCode,stderr:detail}),Error(`ttyd exited immediately (code ${child.exitCode??"unknown"}) on port ${assignedPort} attaching tmux session "${tmuxName}": ${detail}`)}this.ttydPids.set(sessionId,child.pid),this.ttydPorts.set(sessionId,assignedPort),await this.persistTerminals();let terminalInfo={url:`http://localhost:${assignedPort}`,port:assignedPort,pid:child.pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]};return session.terminal=terminalInfo,session.updatedAt=nowISO(),await this.saveSession(session),this.log.info("ttyd terminal started",{sessionId,port:assignedPort,pid:child.pid}),terminalInfo}async stopTerminal(sessionId){let pid=this.ttydPids.get(sessionId);if(!pid){this.log.debug("No ttyd process found for session",{sessionId});return}this.log.info("Stopping ttyd terminal",{sessionId,pid}),await gracefulKill(pid),this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId),await this.persistTerminals();let session=await this.getInfo(sessionId);if(session)session.terminal=void 0,session.updatedAt=nowISO(),await this.saveSession(session);this.log.info("ttyd terminal stopped",{sessionId})}async listSystemSessions(){try{let raw=tmuxExec(["list-sessions","-F","#{session_id}:#{session_name}:#{session_windows}:#{session_attached}:#{session_created}"]);if(!raw)return[];return raw.split(`
|
|
3
|
+
`);return}if(onInit)await onInit(hostServices);if(telemetryEventName)hostServices.telemetry?.emit(telemetryEventName,{plugin:name})},onServerStop:async(hostServices)=>{if(onShutdown)await onShutdown(hostServices)},onNuke:async(hostServices,ctx)=>{if(onNuke)return onNuke(hostServices,ctx)}}}var TypedStore=class{constructor(storage,namespace,key,logger,pluginName="plugin"){this.storage=storage,this.namespace=namespace,this.key=key,this.logger=logger,this.pluginName=pluginName}storage;namespace;key;logger;pluginName;async get(){let raw=await this.storage.get(this.namespace,this.key);if(raw===null||raw===void 0)return null;if(typeof raw!=="string")return raw;try{return JSON.parse(raw)}catch(err){return this.logger?.error?.(this.pluginName,"TypedStore: corrupt JSON",{namespace:this.namespace,key:this.key,message:err instanceof Error?err.message:String(err)}),null}}async set(value){await this.storage.set(this.namespace,this.key,JSON.stringify(value))}async delete(){return this.storage.delete(this.namespace,this.key)}};import{createServer}from"net";function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}function isProcessAlive(pid){try{return process.kill(pid,0),!0}catch{return!1}}async function gracefulKill(pid,timeoutMs=3000,logger){if(!isProcessAlive(pid))return;try{process.kill(pid,"SIGTERM")}catch(err){logger?.warn?.("subprocess","SIGTERM failed",{pid,message:err instanceof Error?err.message:String(err)});return}let pollMs=50,elapsed=0;while(elapsed<timeoutMs)if(await sleep(pollMs),elapsed+=pollMs,!isProcessAlive(pid))return;try{process.kill(pid,"SIGKILL")}catch(err){logger?.warn?.("subprocess","SIGKILL failed",{pid,message:err instanceof Error?err.message:String(err)})}}async function findAvailablePort(start,range=200){for(let i=0;i<range;i++){let port=start+i;if(await isPortFree(port))return port}throw Error(`findAvailablePort: no port free in [${start}, ${start+range-1}]`)}function isPortFree(port){return new Promise((resolve)=>{let server=createServer();server.once("error",()=>{resolve(!1)}),server.once("listening",()=>{server.close(()=>resolve(!0))});try{server.listen({port,host:"0.0.0.0"})}catch{resolve(!1)}})}var BoundLogger=class{constructor(logger,source){this.logger=logger,this.source=source}logger;source;info(message,meta){this.logger?.info?.(this.source,message,meta)}warn(message,meta){this.logger?.warn?.(this.source,message,meta)}error(message,meta){this.logger?.error?.(this.source,message,meta)}debug(message,meta){this.logger?.debug?.(this.source,message,meta)}};var TelemetryEmitter=class{constructor(pluginName,pluginVersion,hostServices){this.pluginName=pluginName,this.pluginVersion=pluginVersion,this.hostServices=hostServices}pluginName;pluginVersion;hostServices;emit(eventName,payload){let target=this.hostServices?.telemetry;if(!target)return;target.emit(eventName,{plugin:this.pluginName,version:this.pluginVersion,timestamp:new Date().toISOString(),...payload??{}})}emitReady(context){this.emit(`${this.pluginName}.ready`,context)}emitError(error,context){this.emit(`${this.pluginName}.error`,{message:error.message,...context??{}})}emitEvent(type,payload){this.emit(type,payload)}};var CONTEXT_PROVIDER_TYPE="context",REGISTRY_KEY=Symbol.for("@vibecontrols/plugin-sdk:contextProviders@1");function getRegistry(){let slots=globalThis,existing=slots[REGISTRY_KEY];if(existing)return existing;let created=new Map;return slots[REGISTRY_KEY]=created,created}function registerContextProvider(provider,hostServices){if(!provider||typeof provider.name!=="string"||provider.name.length===0)throw Error("ContextProvider.name is required");if(typeof provider.getContext!=="function")throw Error("ContextProvider.getContext must be a function");getRegistry().set(provider.name,provider),hostServices?.serviceRegistry?.registerProvider?.(CONTEXT_PROVIDER_TYPE,provider,provider.name)}function listContextProviders(){return Array.from(getRegistry().values())}function getContextProvider(name){return getRegistry().get(name)}var ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;registerContextProvider(provider){registerContextProvider(provider,this.hostServices)}listContextProviders(){return listContextProviders()}getContextProvider(name){return getContextProvider(name)}getServiceRegistry(){return this.hostServices?.serviceRegistry}registerProvider(type,name,provider){this.hostServices?.serviceRegistry?.registerProvider?.(type,provider,name)}getProvider(type,name){let reg=this.hostServices?.serviceRegistry;if(!reg)return;if(name===void 0)return reg.getProvider?.(type);if(reg.getProviderByName)return reg.getProviderByName(type,name);if(!reg.listProvidersForType)return;if(!(reg.listProvidersForType(type)??[]).some((entry)=>typeof entry==="string"?entry===name:entry.pluginName===name))return;return reg.getProvider?.(type)}listProviders(type){return(this.hostServices?.serviceRegistry?.listProvidersForType?.(type)??[]).map((entry)=>typeof entry==="string"?entry:entry.pluginName)}withCliContribution(contribution){let contributors=this.hostServices?.cliContributors;if(!contributors)return;for(let section of contribution.statusSections??[])contributors.addStatusSection?.(section);for(let check of contribution.doctorChecks??[])contributors.addDoctorCheck?.(check)}};import{spawn}from"child_process";import{createHash}from"crypto";import{existsSync,promises,createWriteStream}from"fs";import{homedir,tmpdir}from"os";import*as path from"path";import{Readable}from"stream";import{pipeline}from"stream/promises";function currentPlatform(){let arch=process.arch==="x64"?"x64":"arm64";return`${process.platform==="win32"?"win32":process.platform==="darwin"?"darwin":"linux"}-${arch}`}function toolsCacheRoot(){return path.join(homedir(),".boff","vibecontrols","tools")}function binFileName(name,binaryName){let base=binaryName??name;if(process.platform==="win32"&&!base.toLowerCase().endsWith(".exe"))return`${base}.exe`;return base}function cachedBinaryPath(name,binaryName,cacheRoot){return path.join(cacheRoot??toolsCacheRoot(),name,binFileName(name,binaryName))}function resolveBinary(name,binaryName,cacheRoot){let cached=cachedBinaryPath(name,binaryName,cacheRoot);if(existsSync(cached))return cached;try{let onPath=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which(binaryName??name,{PATH:process.env.PATH}):null;if(onPath)return onPath}catch{}return null}async function versionOk(binaryPath,spec){if(!spec.versionMatcher)return!0;return new Promise((resolve)=>{let child=spawn(binaryPath,spec.versionArgs??["--version"],{stdio:["ignore","pipe","pipe"]}),out="";child.stdout.on("data",(b)=>out+=b.toString()),child.stderr.on("data",(b)=>out+=b.toString()),child.on("close",()=>{try{resolve(new RegExp(spec.versionMatcher).test(out))}catch{resolve(!1)}}),child.on("error",()=>resolve(!1))})}async function installBinary(spec){let platform=spec.platform??currentPlatform(),binaryName=binFileName(spec.name,spec.binaryName),cached=cachedBinaryPath(spec.name,spec.binaryName,spec.cacheRoot);if(existsSync(cached)&&await versionOk(cached,spec))return cached;try{let onPath=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which(spec.binaryName??spec.name,{PATH:process.env.PATH}):null;if(onPath&&await versionOk(onPath,spec))return spec.log?.info?.(`[install] using PATH binary ${spec.name} (${onPath})`),onPath}catch{}let download=spec.downloads[platform];if(!download)throw Error(`[install] no download manifest entry for ${spec.name} on ${platform}`);return spec.log?.info?.(`[install] downloading ${spec.name} for ${platform}`),await promises.mkdir(path.dirname(cached),{recursive:!0}),await downloadAndInstall(download,cached,binaryName,spec.name),spec.log?.info?.(`[install] installed ${spec.name} \u2192 ${cached}`),cached}async function downloadAndInstall(d,destBinary,binaryName,toolName){let destDir=path.dirname(destBinary),tmp=path.join(tmpdir(),`vibe-install-${toolName}-${process.pid}`);await promises.mkdir(tmp,{recursive:!0});let archivePath=path.join(tmp,"artifact");try{let res=await fetch(d.url,{redirect:"follow"});if(!res.ok)throw Error(`download failed (${res.status}) for ${d.url}`);if(!res.body)throw Error(`empty response body for ${d.url}`);if(await pipeline(Readable.fromWeb(res.body),createWriteStream(archivePath)),d.sha256){let actual=createHash("sha256").update(await promises.readFile(archivePath)).digest("hex");if(actual!==d.sha256)throw Error(`sha256 mismatch for ${toolName}: expected ${d.sha256}, got ${actual}`)}let archiveType=d.archive??(d.url.endsWith(".tar.gz")||d.url.endsWith(".tgz")?"tar.gz":d.url.endsWith(".zip")?"zip":"raw");if(archiveType==="raw")await promises.copyFile(archivePath,destBinary);else{if(archiveType==="tar.gz")await runProcess("tar",["-xzf",archivePath,"-C",tmp]);else await extractZip(archivePath,tmp);let inner=d.binaryWithinArchive??binaryName;await promises.copyFile(path.join(tmp,inner),destBinary)}if(process.platform!=="win32")await promises.chmod(destBinary,493)}finally{await promises.rm(tmp,{recursive:!0,force:!0})}}async function extractZip(archive,dest){try{await runProcess("unzip",["-q","-o",archive,"-d",dest]);return}catch(err){if(process.platform!=="win32")throw err;await runProcess("tar",["-xf",archive,"-C",dest])}}async function runProcess(cmd,args){return new Promise((resolve,reject)=>{let child=spawn(cmd,args,{stdio:["ignore","ignore","pipe"]}),err="";child.stderr.on("data",(b)=>err+=b.toString()),child.on("close",(code)=>code===0?resolve():reject(Error(`${cmd} exit ${code}: ${err}`))),child.on("error",reject)})}var CLOUDFLARED_BASE="https://github.com/cloudflare/cloudflared/releases/latest/download",CLOUDFLARED_DOWNLOADS={"linux-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-linux-amd64`,archive:"raw"},"linux-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-linux-arm64`,archive:"raw"},"darwin-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-darwin-amd64.tgz`,archive:"tar.gz",binaryWithinArchive:"cloudflared"},"darwin-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-darwin-arm64.tgz`,archive:"tar.gz",binaryWithinArchive:"cloudflared"},"win32-x64":{url:`${CLOUDFLARED_BASE}/cloudflared-windows-amd64.exe`,archive:"raw"},"win32-arm64":{url:`${CLOUDFLARED_BASE}/cloudflared-windows-arm64.exe`,archive:"raw"}};var TTYD_DOWNLOADS={"linux-x64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64",archive:"raw"},"linux-arm64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.aarch64",archive:"raw"},"win32-x64":{url:"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.win32.exe",archive:"raw"}};async function ensureTtydBinary(log){let existing=resolveBinary("ttyd");if(existing)return existing;log?.info("ttyd not found \u2014 attempting auto-install");try{let installed=await installBinary({name:"ttyd",downloads:TTYD_DOWNLOADS,versionMatcher:"ttyd version",log:{info:(msg)=>log?.info(msg),warn:(msg)=>log?.warn(msg),error:(msg)=>log?.error(msg)}});return log?.info("ttyd auto-installed",{path:installed}),installed}catch(err){let cause=err instanceof Error?err.message:String(err);throw Error(`ttyd is not available and auto-install failed. ${ttydManualInstall()}. Cause: ${cause}`,{cause:err})}}var TTYD_HOST="127.0.0.1",TTYD_WS_PATH="/ws",TTYD_SUBPROTOCOLS=["tty"],PLUGIN_NAME="session-tmux",PLUGIN_VERSION="2.3.0",PROVIDER_NAME="session-tmux",STORAGE_NAMESPACE="session-tmux",STORAGE_KEY_SESSIONS="sessions",STORAGE_KEY_TERMINALS="terminals",TTYD_BASE_PORT=7681,TTYD_PORT_RANGE=200;function generateId(){let bytes=new Uint8Array(4);return crypto.getRandomValues(bytes),[...bytes].map((b)=>b.toString(16).padStart(2,"0")).join("")}function tmuxExec(args){let result=Bun.spawnSync(["tmux",...args],{stdout:"pipe",stderr:"pipe",timeout:1e4});if(result.exitCode!==0){let stderr=result.stderr.toString().trim();throw Error(`tmux exited with code ${result.exitCode}: ${stderr}`)}return result.stdout.toString("utf-8").trimEnd()}function tmuxExecSilent(args){try{return Bun.spawnSync(["tmux",...args],{stdout:"pipe",stderr:"pipe",timeout:1e4}).exitCode===0}catch{return!1}}function applyTmuxSessionDefaults(sessionName,exec=tmuxExecSilent){exec(["set","-t",sessionName,"mouse","off"]),exec(["set","-t",sessionName,"set-clipboard","on"]),exec(["bind-key","-T","copy-mode","MouseDragEnd1Pane","send-keys","-X","copy-pipe-and-cancel"]),exec(["bind-key","-T","copy-mode-vi","MouseDragEnd1Pane","send-keys","-X","copy-pipe-and-cancel"]),exec(["set","-t",sessionName,"pane-border-format"," #[align=centre]#[bg=colour208,fg=black,bold] \u2B06 SCROLL MODE \u2014 press Esc or q to resume typing \u2B06 #[default]"]),exec(["set-hook","-t",sessionName,"pane-mode-changed",'if -F "#{pane_in_mode}" "set-option -w pane-border-status top" "set-option -w pane-border-status off"'])}function nowISO(){return new Date().toISOString()}class TmuxSessionProvider{name=PROVIDER_NAME;log=new BoundLogger(void 0,PLUGIN_NAME);sessionsStore=null;terminalsStore=null;ttydPids=new Map;ttydPorts=new Map;async init(services){if(this.log=new BoundLogger(services.logger,PLUGIN_NAME),services.storage)this.sessionsStore=new TypedStore(services.storage,STORAGE_NAMESPACE,STORAGE_KEY_SESSIONS,services.logger,PLUGIN_NAME),this.terminalsStore=new TypedStore(services.storage,STORAGE_NAMESPACE,STORAGE_KEY_TERMINALS,services.logger,PLUGIN_NAME);this.log.info("TmuxSessionProvider initialising",{provider:this.name});try{let version=tmuxExec(["-V"]);this.log.info("tmux detected",{version})}catch{this.log.error("tmux is not installed or not in PATH \u2014 session provider will not function")}await this.reconcileSessions(),this.log.info("TmuxSessionProvider ready")}async shutdown(context){if(context?.reason==="reload"){this.log.info("Hot-reload: preserving tmux sessions and ttyd processes"),await this.persistTerminals(),this.ttydPids.clear(),this.ttydPorts.clear();return}this.log.info("TmuxSessionProvider shutting down \u2014 stopping terminals");let stopPromises=[];for(let[sessionId]of this.ttydPids)stopPromises.push(this.stopTerminal(sessionId));await Promise.allSettled(stopPromises);try{await this.terminalsStore?.delete()}catch{}this.log.info("TmuxSessionProvider shutdown complete")}async nuke(){this.log.info("TmuxSessionProvider nuke \u2014 reaping spawned terminals");let tracked=[...this.ttydPids.keys()],terminalsReaped=0;await Promise.allSettled(tracked.map(async(sessionId)=>{let pid=this.ttydPids.get(sessionId);if(await this.stopTerminal(sessionId),pid!==void 0)terminalsReaped++}));try{let raw=Bun.spawnSync(["pgrep","-a","ttyd"],{stdout:"pipe",stderr:"pipe",timeout:5000}).stdout.toString().trim();if(raw){let stillTracked=new Set(this.ttydPids.values());for(let line of raw.split(`
|
|
4
|
+
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid||stillTracked.has(pid))continue;let tIdx=parts.indexOf("-t"),tmuxTarget=tIdx!==-1?parts[tIdx+1]:null;if(!tmuxTarget||!tmuxTarget.startsWith("vibe-"))continue;if(!isProcessAlive(pid))continue;this.log.info("Reaping untracked ttyd attached to vibe session",{pid,tmuxTarget}),await gracefulKill(pid),terminalsReaped++}}}catch{}this.ttydPids.clear(),this.ttydPorts.clear();try{await this.terminalsStore?.delete()}catch{}try{await this.sessionsStore?.delete()}catch{}return this.log.info("TmuxSessionProvider nuke complete",{terminalsReaped}),{terminalsReaped}}async create(config){if(config.externalName){let target=config.externalName;if(target.length===0||target.length>128||/[\0\r\n:.\s]/.test(target))throw Error(`Invalid externalName: ${target}`);if(tmuxExecSilent(["has-session","-t",target]))return this.log.info("create(): attaching to existing tmux session",{target}),this.adopt(target,config.name);let now2=nowISO(),args=["new-session","-d","-s",target];if(config.workingDirectory)args.push("-c",config.workingDirectory);if(config.size)args.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args.push(config.shell);if(tmuxExec(args),config.environment)for(let[k,v]of Object.entries(config.environment))tmuxExecSilent(["set-environment","-t",target,k,v]);if(applyTmuxSessionDefaults(target),config.command)tmuxExecSilent(["send-keys","-t",target,config.command,"Enter"]);let id2=target.startsWith("vibe-")?target.slice(5)||target:target,pid2=this.getSessionPanePid(target),info2={id:id2,name:config.name,status:"active",provider:this.name,command:config.command,workingDirectory:config.workingDirectory,pid:pid2??void 0,projectId:config.projectId,createdAt:now2,updatedAt:now2,metadata:{tmuxSessionName:target,shell:config.shell,size:config.size,createdWithExplicitName:!0}};return await this.saveSession(info2),this.log.info("Tmux session created with explicit name",{id:id2,target}),info2}let id=config.id||generateId(),sessionName=`vibe-${id.substring(0,8)}`,now=nowISO();this.log.info("Creating tmux session",{id,name:config.name,sessionName});let alreadyRunning=tmuxExecSilent(["has-session","-t",sessionName]);if(alreadyRunning)this.log.info("Reusing existing tmux session on resume",{id,sessionName});else{let args=["new-session","-d","-s",sessionName];if(config.workingDirectory)args.push("-c",config.workingDirectory);if(config.size)args.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args.push(config.shell);try{tmuxExec(args)}catch(err){throw this.log.error("Failed to create tmux session",{id,error:String(err)}),Error(`Failed to create tmux session: ${err}`,{cause:err})}}if(config.environment)for(let[key,value]of Object.entries(config.environment))tmuxExecSilent(["set-environment","-t",sessionName,key,value]);if(applyTmuxSessionDefaults(sessionName),config.command&&!alreadyRunning)tmuxExecSilent(["send-keys","-t",sessionName,config.command,"Enter"]);let pid=this.getSessionPanePid(sessionName),info={id,name:config.name,status:"active",provider:this.name,command:config.command,workingDirectory:config.workingDirectory,pid:pid??void 0,projectId:config.projectId,createdAt:now,updatedAt:now,metadata:{tmuxSessionName:sessionName,shell:config.shell,size:config.size}};return await this.saveSession(info),this.log.info("Tmux session created",{id,sessionName,pid}),info}async terminate(sessionId){let session=await this.getInfo(sessionId);if(!session){this.log.warn("Terminate called for unknown session",{sessionId});return}let tmuxName=this.getTmuxName(session);if(this.log.info("Terminating tmux session",{sessionId,tmuxName}),this.ttydPids.has(sessionId))await this.stopTerminal(sessionId);tmuxExecSilent(["kill-session","-t",tmuxName]),session.status="terminated",session.updatedAt=nowISO(),session.terminal=void 0,await this.saveSession(session),this.log.info("Tmux session terminated",{sessionId})}async getInfo(sessionId){let session=(await this.loadSessions()).find((s)=>s.id===sessionId)??null;if(session){let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(!exists&&session.status==="active")session.status="inactive",session.updatedAt=nowISO(),await this.saveSession(session);else if(exists&&session.status==="inactive")session.status="active",session.updatedAt=nowISO(),await this.saveSession(session);let termInfo=this.getRunningTerminalInfo(sessionId);if(termInfo)session.terminal=termInfo}return session}async list(){let sessions=await this.loadSessions();for(let session of sessions){if(session.status==="terminated")continue;let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(!exists&&session.status==="active")session.status="inactive",session.updatedAt=nowISO();else if(exists&&session.status!=="active")session.status="active",session.updatedAt=nowISO();let termInfo=this.getRunningTerminalInfo(session.id);if(termInfo)session.terminal=termInfo}return await this.saveSessions(sessions),sessions}async sendCommand(sessionId,command){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending command",{sessionId,command});try{tmuxExec(["send-keys","-t",tmuxName,command,"Enter"])}catch(err){throw this.log.error("Failed to send command",{sessionId,error:String(err)}),Error(`Failed to send command to session ${sessionId}: ${err}`,{cause:err})}}async sendKeys(sessionId,keys){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending keys",{sessionId,keys});try{tmuxExec(["send-keys","-t",tmuxName,keys])}catch(err){throw this.log.error("Failed to send keys",{sessionId,error:String(err)}),Error(`Failed to send keys to session ${sessionId}: ${err}`,{cause:err})}}async sendInterrupt(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Sending interrupt (C-c)",{sessionId});try{tmuxExec(["send-keys","-t",tmuxName,"C-c"])}catch(err){throw this.log.error("Failed to send interrupt",{sessionId,error:String(err)}),Error(`Failed to send interrupt to session ${sessionId}: ${err}`,{cause:err})}}async captureOutput(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Capturing output",{sessionId});try{return tmuxExec(["capture-pane","-t",tmuxName,"-p"])}catch(err){throw this.log.error("Failed to capture output",{sessionId,error:String(err)}),Error(`Failed to capture output from session ${sessionId}: ${err}`,{cause:err})}}async rename(sessionId,newName){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.info("Renaming session",{sessionId,from:session.name,to:newName});try{session.name=newName,session.updatedAt=nowISO(),await this.saveSession(session)}catch(err){throw this.log.error("Failed to rename session",{sessionId,error:String(err)}),Error(`Failed to rename session ${sessionId}: ${err}`,{cause:err})}let newTmuxName=`vibe-${session.id}`;tmuxExecSilent(["rename-session","-t",tmuxName,newTmuxName])}async toggleMouse(sessionId){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session),mouseOn;try{mouseOn=tmuxExec(["show-options","-t",tmuxName,"-v","mouse"]).trim()==="on"}catch{mouseOn=!1}let newState=mouseOn?"off":"on";return tmuxExecSilent(["set","-t",tmuxName,"mouse",newState]),this.log.debug("Toggled mouse",{sessionId,mouse:newState}),newState==="on"}async getTerminationStatus(sessionId){let session=(await this.loadSessions()).find((s)=>s.id===sessionId);if(!session)return{terminated:!0,exists:!1};let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);return{terminated:session.status==="terminated"||!exists,exists}}async getTerminalInfo(sessionId){return this.getRunningTerminalInfo(sessionId)}async startTerminal(sessionId,port){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session),existing=this.getRunningTerminalInfo(sessionId);if(existing)return this.log.debug("Terminal already running",{sessionId,...existing}),existing;let inUsePorts=[...this.ttydPorts.values()],scanStart=inUsePorts.length?Math.max(TTYD_BASE_PORT,Math.max(...inUsePorts)+1):TTYD_BASE_PORT,assignedPort=port??await findAvailablePort(scanStart,TTYD_PORT_RANGE);this.log.info("Starting ttyd terminal",{sessionId,tmuxName,port:assignedPort});let ttydEnv={...process.env};ttydEnv.VIBECONTROLS_PROVIDER="tmux";let otherProviderVars={wezterm:["WEZTERM_PANE","WEZTERM_UNIX_SOCKET"],screen:["STY","WINDOW"],zellij:["ZELLIJ","ZELLIJ_SESSION_NAME","ZELLIJ_PANE_ID"]};for(let vars of Object.values(otherProviderVars))for(let v of vars)if(v in ttydEnv)delete ttydEnv[v];let ttydCmd=await ensureTtydBinary(this.log),child=Bun.spawn([ttydCmd,"-t","fontSize=14","-t",'theme={"background":"#1e1e1e","foreground":"#cccccc"}',"-t","macOptionClickForcesSelection=true","-t","rightClickSelectsWord=true","-t","scrollback=10000","--writable","--port",String(assignedPort),"tmux","attach","-t",tmuxName],{stdout:"ignore",stderr:"pipe",stdin:"ignore",env:ttydEnv});if(!child.pid)throw Error("Failed to start ttyd \u2014 no PID returned");let ttydStderr="",stderrStream=child.stderr;if(stderrStream&&typeof stderrStream.getReader==="function")(async()=>{try{let reader=stderrStream.getReader(),dec=new TextDecoder;for(;;){let{done,value}=await reader.read();if(done)break;if(ttydStderr.length<4096)ttydStderr+=dec.decode(value,{stream:!0})}}catch{}})();if(await sleep(500),child.exitCode!==null||!isProcessAlive(child.pid)){let detail=ttydStderr.trim().slice(0,300)||"no stderr captured";throw this.log.error("ttyd exited immediately after launch",{sessionId,tmuxName,port:assignedPort,exitCode:child.exitCode,stderr:detail}),Error(`ttyd exited immediately (code ${child.exitCode??"unknown"}) on port ${assignedPort} attaching tmux session "${tmuxName}": ${detail}`)}this.ttydPids.set(sessionId,child.pid),this.ttydPorts.set(sessionId,assignedPort),await this.persistTerminals();let terminalInfo={url:`http://localhost:${assignedPort}`,port:assignedPort,pid:child.pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]};return session.terminal=terminalInfo,session.updatedAt=nowISO(),await this.saveSession(session),this.log.info("ttyd terminal started",{sessionId,port:assignedPort,pid:child.pid}),terminalInfo}async stopTerminal(sessionId){let pid=this.ttydPids.get(sessionId);if(!pid){this.log.debug("No ttyd process found for session",{sessionId});return}this.log.info("Stopping ttyd terminal",{sessionId,pid}),await gracefulKill(pid),this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId),await this.persistTerminals();let session=await this.getInfo(sessionId);if(session)session.terminal=void 0,session.updatedAt=nowISO(),await this.saveSession(session);this.log.info("ttyd terminal stopped",{sessionId})}async listSystemSessions(){try{let raw=tmuxExec(["list-sessions","-F","#{session_id}:#{session_name}:#{session_windows}:#{session_attached}:#{session_created}"]);if(!raw)return[];return raw.split(`
|
|
5
5
|
`).map((line)=>{let[id,name,windows,attached,created]=line.split(":");return{id:id??"",name:name??"",windows:parseInt(windows??"0",10),attached:attached==="1",createdAt:created?new Date(parseInt(created,10)*1000).toISOString():void 0}})}catch{return[]}}async listSystemTerminals(){let terminals=[];for(let[sessionId,pid]of this.ttydPids){let port=this.ttydPorts.get(sessionId);if(pid&&port!==void 0)terminals.push({pid,port,sessionId})}try{let raw=Bun.spawnSync(["pgrep","-a","ttyd"],{stdout:"pipe",stderr:"pipe",timeout:5000}).stdout.toString().trim();if(raw)for(let line of raw.split(`
|
|
6
|
-
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid)continue;if([...this.ttydPids.values()].includes(pid))continue;let portIdx=parts.indexOf("--port"),port=portIdx!==-1?parseInt(parts[portIdx+1]??"0",10):0;terminals.push({pid,port:port||0})}}catch{}return terminals}async bulkKillSystemSessions(sessionIds){let killed=0,failed=0;for(let sid of sessionIds)if(tmuxExecSilent(["kill-session","-t",sid]))killed++;else failed++;return this.log.info("Bulk kill system sessions",{killed,failed}),{killed,failed}}async bulkKillSystemTerminals(pids){let killed=0,failed=0,killPromises=pids.map(async(pid)=>{try{await gracefulKill(pid),killed++;for(let[sessionId,trackedPid]of this.ttydPids)if(trackedPid===pid){this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId);break}}catch{failed++}});return await Promise.allSettled(killPromises),this.log.info("Bulk kill system terminals",{killed,failed}),{killed,failed}}async healthCheck(){let tmuxOk,tmuxVersion,sessionCount=0,terminalCount=this.ttydPids.size;try{tmuxVersion=tmuxExec(["-V"]),tmuxOk=!0}catch{return{ok:!1,sessions:0,terminals:terminalCount,message:"tmux is not available"}}try{sessionCount=(await this.listSystemSessions()).length}catch{}let ttydOk=!1;try{ttydOk=resolveBinary("ttyd")!==null||Bun.which("ttyd",{PATH:process.env.PATH})!==null}catch{}let messages=[tmuxVersion];if(!ttydOk)messages.push("ttyd not found \u2014 web terminals unavailable");return{ok:tmuxOk,sessions:sessionCount,terminals:terminalCount,message:messages.join("; ")}}async getSessionsByProject(projectId){return(await this.list()).filter((s)=>s.projectId===projectId)}async cleanup(){this.log.info("Running cleanup");let sessions=await this.loadSessions(),cleaned=0,kept=[];for(let session of sessions){let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(session.status==="terminated"||!exists){if(this.ttydPids.has(session.id))await this.stopTerminal(session.id);if(exists&&session.status==="terminated")tmuxExecSilent(["kill-session","-t",tmuxName]);cleaned++,this.log.debug("Cleaned session",{id:session.id,name:session.name})}else kept.push(session)}return await this.saveSessions(kept),this.log.info("Cleanup complete",{cleaned,remaining:kept.length}),{cleaned}}async get(sessionId){return this.getInfo(sessionId)}async kill(sessionId){return this.terminate(sessionId)}async interrupt(sessionId){return this.sendInterrupt(sessionId)}async capture(sessionId){return this.captureOutput(sessionId)}async resize(sessionId,cols,rows){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);tmuxExecSilent(["resize-window","-t",tmuxName,"-x",String(cols),"-y",String(rows)])}async listSystem(){return this.listSystemSessions()}async killSystem(sessionId){tmuxExecSilent(["kill-session","-t",sessionId])}async killSystemTerminal(pid){await gracefulKill(pid);for(let[sessionId,trackedPid]of this.ttydPids)if(trackedPid===pid){this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId);break}}async discoverOrphans(){let known=new Set((await this.loadSessions()).filter((s)=>s.status!=="terminated").map((s)=>this.getTmuxName(s)));return(await this.listSystemSessions()).filter((s)=>s.name.startsWith("vibe-")&&!known.has(s.name)).map((s)=>({externalName:s.name,provider:"tmux",windows:s.windows,attached:s.attached,createdAt:s.createdAt}))}async adopt(externalName,displayName){let tmuxName=externalName;if(!tmuxName.startsWith("vibe-"))throw Error(`Refusing to adopt tmux session "${tmuxName}" \u2014 only vibe-* sessions are adoptable`);if(!tmuxExecSilent(["has-session","-t",tmuxName]))throw Error(`Tmux session "${tmuxName}" does not exist`);let id=tmuxName.slice(5)||tmuxName,existing=(await this.loadSessions()).find((s)=>s.id===id);if(existing){if(this.log.info("Adopt: session already tracked",{id,tmuxName}),!this.ttydPids.has(id))try{await this.startTerminal(id)}catch(err){this.log.warn("Adopt: failed to start ttyd for tracked session",{id,error:String(err)})}return await this.getInfo(id)??existing}let now=nowISO(),pid=this.getSessionPanePid(tmuxName),info={id,name:displayName||tmuxName,status:"active",provider:this.name,pid:pid??void 0,createdAt:now,updatedAt:now,metadata:{tmuxSessionName:tmuxName,adopted:!0}};await this.saveSession(info),this.log.info("Adopted orphan tmux session",{id,tmuxName});try{let term=await this.startTerminal(id);info.terminal=term,await this.saveSession(info)}catch(err){this.log.warn("Adopt: ttyd start failed (session still adopted)",{id,error:String(err)})}return info}getCapabilities(){return{provider:"tmux",features:{mouse:!0,resize:!0,capture:!0,webTerminal:!0,splitPanes:!0,tabs:!1,scrollback:!0,clipboard:!0,search:!0},platform:["linux","macos"]}}async getScrollback(sessionId,lines){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Getting scrollback",{sessionId,lines});try{return tmuxExec(["capture-pane","-t",tmuxName,"-p","-S",`-${lines}`])}catch(err){throw this.log.error("Failed to get scrollback",{sessionId,error:String(err)}),Error(`Failed to get scrollback for session ${sessionId}: ${err}`,{cause:err})}}async searchOutput(sessionId,pattern){let lines=(await this.getScrollback(sessionId,1e4)).split(`
|
|
6
|
+
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid)continue;if([...this.ttydPids.values()].includes(pid))continue;let portIdx=parts.indexOf("--port"),port=portIdx!==-1?parseInt(parts[portIdx+1]??"0",10):0;terminals.push({pid,port:port||0})}}catch{}return terminals}async bulkKillSystemSessions(sessionIds){let killed=0,failed=0;for(let sid of sessionIds)if(tmuxExecSilent(["kill-session","-t",sid]))killed++;else failed++;return this.log.info("Bulk kill system sessions",{killed,failed}),{killed,failed}}async bulkKillSystemTerminals(pids){let killed=0,failed=0,killPromises=pids.map(async(pid)=>{try{await gracefulKill(pid),killed++;for(let[sessionId,trackedPid]of this.ttydPids)if(trackedPid===pid){this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId);break}}catch{failed++}});return await Promise.allSettled(killPromises),this.log.info("Bulk kill system terminals",{killed,failed}),{killed,failed}}async healthCheck(){let tmuxOk,tmuxVersion,sessionCount=0,terminalCount=this.ttydPids.size;try{tmuxVersion=tmuxExec(["-V"]),tmuxOk=!0}catch{return{ok:!1,sessions:0,terminals:terminalCount,message:"tmux is not available"}}try{sessionCount=(await this.listSystemSessions()).length}catch{}let ttydOk=!1;try{ttydOk=resolveBinary("ttyd")!==null||Bun.which("ttyd",{PATH:process.env.PATH})!==null}catch{}let messages=[tmuxVersion];if(!ttydOk)messages.push("ttyd not found \u2014 web terminals unavailable");return{ok:tmuxOk,sessions:sessionCount,terminals:terminalCount,message:messages.join("; ")}}async getSessionsByProject(projectId){return(await this.list()).filter((s)=>s.projectId===projectId)}async cleanup(){this.log.info("Running cleanup");let sessions=await this.loadSessions(),cleaned=0,kept=[];for(let session of sessions){let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(session.status==="terminated"||!exists){if(this.ttydPids.has(session.id))await this.stopTerminal(session.id);if(exists&&session.status==="terminated")tmuxExecSilent(["kill-session","-t",tmuxName]);cleaned++,this.log.debug("Cleaned session",{id:session.id,name:session.name})}else kept.push(session)}return await this.saveSessions(kept),this.log.info("Cleanup complete",{cleaned,remaining:kept.length}),{cleaned}}async get(sessionId){return this.getInfo(sessionId)}async kill(sessionId){return this.terminate(sessionId)}async interrupt(sessionId){return this.sendInterrupt(sessionId)}async capture(sessionId){return this.captureOutput(sessionId)}async resize(sessionId,cols,rows){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);tmuxExecSilent(["resize-window","-t",tmuxName,"-x",String(cols),"-y",String(rows)])}async listSystem(){return this.listSystemSessions()}async killSystem(sessionId){tmuxExecSilent(["kill-session","-t",sessionId])}async killSystemTerminal(pid){await gracefulKill(pid);for(let[sessionId,trackedPid]of this.ttydPids)if(trackedPid===pid){this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId);break}}async discoverOrphans(){let known=new Set((await this.loadSessions()).filter((s)=>s.status!=="terminated").map((s)=>this.getTmuxName(s)));return(await this.listSystemSessions()).filter((s)=>s.name.startsWith("vibe-")&&!known.has(s.name)).map((s)=>({externalName:s.name,provider:"tmux",windows:s.windows,attached:s.attached,createdAt:s.createdAt}))}async adopt(externalName,displayName){let tmuxName=externalName;if(!tmuxName.startsWith("vibe-"))throw Error(`Refusing to adopt tmux session "${tmuxName}" \u2014 only vibe-* sessions are adoptable`);if(!tmuxExecSilent(["has-session","-t",tmuxName]))throw Error(`Tmux session "${tmuxName}" does not exist`);let id=tmuxName.slice(5)||tmuxName,existing=(await this.loadSessions()).find((s)=>s.id===id);if(existing){if(this.log.info("Adopt: session already tracked",{id,tmuxName}),!this.ttydPids.has(id))try{await this.startTerminal(id)}catch(err){this.log.warn("Adopt: failed to start ttyd for tracked session",{id,error:String(err)})}return await this.getInfo(id)??existing}applyTmuxSessionDefaults(tmuxName);let now=nowISO(),pid=this.getSessionPanePid(tmuxName),info={id,name:displayName||tmuxName,status:"active",provider:this.name,pid:pid??void 0,createdAt:now,updatedAt:now,metadata:{tmuxSessionName:tmuxName,adopted:!0}};await this.saveSession(info),this.log.info("Adopted orphan tmux session",{id,tmuxName});try{let term=await this.startTerminal(id);info.terminal=term,await this.saveSession(info)}catch(err){this.log.warn("Adopt: ttyd start failed (session still adopted)",{id,error:String(err)})}return info}getCapabilities(){return{provider:"tmux",features:{mouse:!0,resize:!0,capture:!0,webTerminal:!0,splitPanes:!0,tabs:!1,scrollback:!0,clipboard:!0,search:!0},platform:["linux","macos"]}}async getScrollback(sessionId,lines){let session=await this.requireSession(sessionId),tmuxName=this.getTmuxName(session);this.log.debug("Getting scrollback",{sessionId,lines});try{return tmuxExec(["capture-pane","-t",tmuxName,"-p","-S",`-${lines}`])}catch(err){throw this.log.error("Failed to get scrollback",{sessionId,error:String(err)}),Error(`Failed to get scrollback for session ${sessionId}: ${err}`,{cause:err})}}async searchOutput(sessionId,pattern){let lines=(await this.getScrollback(sessionId,1e4)).split(`
|
|
7
7
|
`),results=[],regex;try{regex=new RegExp(pattern)}catch{regex=new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"))}for(let i=0;i<lines.length;i++)if(regex.test(lines[i]??""))results.push({line:i+1,content:lines[i]??""});return this.log.debug("Search output completed",{sessionId,pattern,matches:results.length}),results}async loadSessions(){if(!this.sessionsStore)return[];return await this.sessionsStore.get()??[]}async saveSessions(sessions){if(!this.sessionsStore)return;try{await this.sessionsStore.set(sessions)}catch(err){this.log.error("Failed to save sessions to storage",{error:String(err)})}}async saveSession(session){let sessions=await this.loadSessions(),idx=sessions.findIndex((s)=>s.id===session.id);if(idx>=0)sessions[idx]=session;else sessions.push(session);await this.saveSessions(sessions)}getTmuxName(session){let meta=session.metadata;if(meta&&typeof meta.tmuxSessionName==="string")return meta.tmuxSessionName;return`vibe-${session.id}`}getSessionPanePid(tmuxName){try{let raw=tmuxExec(["list-panes","-t",tmuxName,"-F","#{pane_pid}"]),pid=parseInt(raw.split(`
|
|
8
8
|
`)[0]??"",10);return isNaN(pid)?null:pid}catch{return null}}async requireSession(sessionId){let session=await this.getInfo(sessionId);if(!session)throw Error(`Session not found: ${sessionId}`);if(session.status==="terminated")throw Error(`Session is terminated: ${sessionId}`);return session}getRunningTerminalInfo(sessionId){let pid=this.ttydPids.get(sessionId),port=this.ttydPorts.get(sessionId);if(!pid||port===void 0)return null;if(!isProcessAlive(pid))return this.ttydPids.delete(sessionId),this.ttydPorts.delete(sessionId),null;return{url:`http://localhost:${port}`,port,pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]}}async persistTerminals(){if(!this.terminalsStore)return;let data={};for(let[sessionId,pid]of this.ttydPids){let port=this.ttydPorts.get(sessionId);if(port!==void 0)data[sessionId]={pid,port}}try{await this.terminalsStore.set(data)}catch{this.log.warn("Failed to persist terminal state")}}async loadPersistedTerminals(){if(!this.terminalsStore)return{};return await this.terminalsStore.get()??{}}async reconcileSessions(){let sessions=await this.loadSessions(),persistedTerminals=await this.loadPersistedTerminals(),changed=!1;for(let session of sessions){if(session.status==="terminated")continue;let tmuxName=this.getTmuxName(session),exists=tmuxExecSilent(["has-session","-t",tmuxName]);if(!exists){if(session.status==="active")session.status="inactive",session.updatedAt=nowISO(),session.terminal=void 0,changed=!0,this.log.info("Reconciled: tmux session gone, marking inactive",{id:session.id});let termData2=persistedTerminals[session.id];if(termData2&&isProcessAlive(termData2.pid))this.log.info("Killing orphaned ttyd for dead tmux session",{sessionId:session.id,pid:termData2.pid}),await gracefulKill(termData2.pid);delete persistedTerminals[session.id];continue}let termData=persistedTerminals[session.id];if(termData)if(isProcessAlive(termData.pid))this.ttydPids.set(session.id,termData.pid),this.ttydPorts.set(session.id,termData.port),session.terminal={url:`http://localhost:${termData.port}`,port:termData.port,pid:termData.pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]},session.status="active",session.updatedAt=nowISO(),changed=!0,this.log.info("Recovered ttyd process",{sessionId:session.id,pid:termData.pid,port:termData.port});else session.terminal=void 0,changed=!0,delete persistedTerminals[session.id],this.log.info("Stale ttyd record cleared (process dead)",{sessionId:session.id});if(exists&&session.status!=="active")session.status="active",session.updatedAt=nowISO(),changed=!0}try{let raw=Bun.spawnSync(["pgrep","-a","ttyd"],{stdout:"pipe",stderr:"pipe",timeout:5000}).stdout.toString().trim();if(raw)for(let line of raw.split(`
|
|
9
|
-
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid)continue;if([...this.ttydPids.values()].includes(pid))continue;let tIdx=parts.indexOf("-t"),tmuxTarget=tIdx!==-1?parts[tIdx+1]:null;if(!tmuxTarget)continue;let matchedSession=sessions.find((s)=>s.status!=="terminated"&&this.getTmuxName(s)===tmuxTarget);if(!matchedSession)continue;let portIdx=parts.indexOf("--port"),port=portIdx!==-1?parseInt(parts[portIdx+1]??"0",10):0;if(!port)continue;this.ttydPids.set(matchedSession.id,pid),this.ttydPorts.set(matchedSession.id,port),matchedSession.terminal={url:`http://localhost:${port}`,port,pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]},matchedSession.status="active",matchedSession.updatedAt=nowISO(),changed=!0,this.log.info("Adopted orphaned ttyd process",{sessionId:matchedSession.id,pid,port,tmuxTarget})}}catch{}if(changed)await this.saveSessions(sessions);await this.persistTerminals()}}var provider=new TmuxSessionProvider;function whichSync(bin){return Bun.which(bin,{PATH:process.env.PATH})??null}function ttydManualInstall(){return process.platform==="darwin"?"brew install ttyd":process.platform==="win32"?"winget install tsl0922.ttyd # or download from https://github.com/tsl0922/ttyd/releases":"sudo apt-get install -y ttyd # or build from source: https://github.com/tsl0922/ttyd"}function tmuxManualInstall(){return process.platform==="darwin"?"brew install tmux":"sudo apt-get install -y tmux"}function createPrereqsRoutes(){return new Elysia({prefix:"/prereqs"}).get("/status",()=>{let ttydOk=resolveBinary("ttyd")!==null||!!whichSync("ttyd"),tmuxOk=!!whichSync("tmux"),missing=[];if(!tmuxOk)missing.push({name:"tmux",kind:"binary",requiresSudo:!0});if(!ttydOk)missing.push({name:"ttyd",kind:"binary",requiresSudo:!1});return{satisfied:missing.length===0,missing}}).post("/install",async()=>{let installed=[],pendingSudo=[],errors=[];if(!whichSync("tmux"))pendingSudo.push({name:"tmux",command:tmuxManualInstall(),reason:"tmux is required for the tmux session backend."});if(resolveBinary("ttyd")||whichSync("ttyd"));else try{await installBinary({name:"ttyd",downloads:TTYD_DOWNLOADS,versionMatcher:"ttyd version"}),installed.push("ttyd")}catch(err){let message=err instanceof Error?err.message:String(err);pendingSudo.push({name:"ttyd",command:ttydManualInstall(),reason:`ttyd auto-download failed: ${message}`}),errors.push(message)}return{ok:errors.length===0,installed,pendingSudo,errors}}).post("/uninstall",()=>({ok:!0}))}var createPlugin=(_ctx)=>{let lifecycle=createLifecycleHooks({name:PLUGIN_NAME,skipPlatforms:["win32"],telemetryEventName:`${PLUGIN_NAME}.ready`,onInit:async(services)=>{new ProviderRegistry(services).registerProvider("session",PROVIDER_NAME,provider),new TelemetryEmitter(PLUGIN_NAME,PLUGIN_VERSION,services).emit("session.provider.ready",{provider:"tmux"}),await provider.init(services)},onShutdown:async()=>{await provider.shutdown({reason:"shutdown"})},onNuke:async(_hostServices,ctx)=>{if(ctx.dryRun)return{reaped:["ttyd terminal servers + session-tmux storage"]};let{terminalsReaped}=await provider.nuke();return{reaped:[`${terminalsReaped} ttyd terminal server${terminalsReaped===1?"":"s"} + session-tmux storage`]}}});return{capabilities:{storage:"rw",subprocess:!0,telemetry:!0},name:PLUGIN_NAME,version:PLUGIN_VERSION,description:"Tmux + ttyd session provider \u2014 manages terminal sessions via tmux and exposes web terminals via ttyd",tags:["backend","provider"],apiPrefix:"/api/session-tmux",prerequisites:[{name:"tmux",kind:"binary",requiresSudo:!0},{name:"ttyd",kind:"binary",requiresSudo:!1}],createRoutes:()=>createPrereqsRoutes(),onServerStart:lifecycle.onServerStart,onServerStop:lifecycle.onServerStop,onNuke:lifecycle.onNuke}};export{createPlugin};
|
|
9
|
+
`)){let parts=line.trim().split(/\s+/),pid=parseInt(parts[0]??"0",10);if(!pid)continue;if([...this.ttydPids.values()].includes(pid))continue;let tIdx=parts.indexOf("-t"),tmuxTarget=tIdx!==-1?parts[tIdx+1]:null;if(!tmuxTarget)continue;let matchedSession=sessions.find((s)=>s.status!=="terminated"&&this.getTmuxName(s)===tmuxTarget);if(!matchedSession)continue;let portIdx=parts.indexOf("--port"),port=portIdx!==-1?parseInt(parts[portIdx+1]??"0",10):0;if(!port)continue;this.ttydPids.set(matchedSession.id,pid),this.ttydPorts.set(matchedSession.id,port),matchedSession.terminal={url:`http://localhost:${port}`,port,pid,host:TTYD_HOST,wsPath:TTYD_WS_PATH,subprotocols:[...TTYD_SUBPROTOCOLS]},matchedSession.status="active",matchedSession.updatedAt=nowISO(),changed=!0,this.log.info("Adopted orphaned ttyd process",{sessionId:matchedSession.id,pid,port,tmuxTarget})}}catch{}if(changed)await this.saveSessions(sessions);await this.persistTerminals()}}var provider=new TmuxSessionProvider;function whichSync(bin){return Bun.which(bin,{PATH:process.env.PATH})??null}function ttydManualInstall(){return process.platform==="darwin"?"brew install ttyd":process.platform==="win32"?"winget install tsl0922.ttyd # or download from https://github.com/tsl0922/ttyd/releases":"sudo apt-get install -y ttyd # or build from source: https://github.com/tsl0922/ttyd"}function tmuxManualInstall(){return process.platform==="darwin"?"brew install tmux":"sudo apt-get install -y tmux"}function createPrereqsRoutes(){return new Elysia({prefix:"/prereqs"}).get("/status",()=>{let ttydOk=resolveBinary("ttyd")!==null||!!whichSync("ttyd"),tmuxOk=!!whichSync("tmux"),missing=[];if(!tmuxOk)missing.push({name:"tmux",kind:"binary",requiresSudo:!0});if(!ttydOk)missing.push({name:"ttyd",kind:"binary",requiresSudo:!1});return{satisfied:missing.length===0,missing}}).post("/install",async()=>{let installed=[],pendingSudo=[],errors=[];if(!whichSync("tmux"))pendingSudo.push({name:"tmux",command:tmuxManualInstall(),reason:"tmux is required for the tmux session backend."});if(resolveBinary("ttyd")||whichSync("ttyd"));else try{await installBinary({name:"ttyd",downloads:TTYD_DOWNLOADS,versionMatcher:"ttyd version"}),installed.push("ttyd")}catch(err){let message=err instanceof Error?err.message:String(err);pendingSudo.push({name:"ttyd",command:ttydManualInstall(),reason:`ttyd auto-download failed: ${message}`}),errors.push(message)}return{ok:errors.length===0,installed,pendingSudo,errors}}).post("/uninstall",()=>({ok:!0}))}var createPlugin=(_ctx)=>{let lifecycle=createLifecycleHooks({name:PLUGIN_NAME,skipPlatforms:["win32"],telemetryEventName:`${PLUGIN_NAME}.ready`,onInit:async(services)=>{new ProviderRegistry(services).registerProvider("session",PROVIDER_NAME,provider),new TelemetryEmitter(PLUGIN_NAME,PLUGIN_VERSION,services).emit("session.provider.ready",{provider:"tmux"}),await provider.init(services)},onShutdown:async()=>{await provider.shutdown({reason:"shutdown"})},onNuke:async(_hostServices,ctx)=>{if(ctx.dryRun)return{reaped:["ttyd terminal servers + session-tmux storage"]};let{terminalsReaped}=await provider.nuke();return{reaped:[`${terminalsReaped} ttyd terminal server${terminalsReaped===1?"":"s"} + session-tmux storage`]}}});return{capabilities:{storage:"rw",subprocess:!0,telemetry:!0},name:PLUGIN_NAME,version:PLUGIN_VERSION,description:"Tmux + ttyd session provider \u2014 manages terminal sessions via tmux and exposes web terminals via ttyd",tags:["backend","provider"],apiPrefix:"/api/session-tmux",prerequisites:[{name:"tmux",kind:"binary",requiresSudo:!0},{name:"ttyd",kind:"binary",requiresSudo:!1}],createRoutes:()=>createPrereqsRoutes(),onServerStart:lifecycle.onServerStart,onServerStop:lifecycle.onServerStop,onNuke:lifecycle.onNuke}};export{createPlugin,applyTmuxSessionDefaults};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecontrols/vibe-plugin-session-tmux",
|
|
3
|
-
"version": "2026.608.
|
|
3
|
+
"version": "2026.608.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"description": "Tmux + ttyd session provider plugin for VibeControls Agent",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"format": "bunx prettier . --write",
|
|
21
21
|
"format:check": "bunx prettier . --check",
|
|
22
22
|
"type:check": "tsc --noEmit",
|
|
23
|
+
"test": "bun test",
|
|
23
24
|
"clean": "rimraf dist",
|
|
24
25
|
"prebuild": "bun run clean",
|
|
25
26
|
"prepublishOnly": "bun run build",
|