@vibecontrols/vibe-plugin-session-tmux 2026.509.3 → 2026.510.1

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.
Files changed (2) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  import{Elysia}from"elysia";function createLifecycleHooks(spec){let{name,onInit,onShutdown,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)}}}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:"127.0.0.1"})}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 ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;getServiceRegistry(){return this.hostServices?.serviceRegistry}registerProvider(type,name,provider){this.hostServices?.serviceRegistry?.registerService(type,name,provider)}getProvider(type,name){return this.hostServices?.serviceRegistry?.getService(type,name)}listProviders(type){return this.hostServices?.serviceRegistry?.listProvidersForType?.(type)??[]}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)}};var 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 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(),args2=["new-session","-d","-s",target];if(config.workingDirectory)args2.push("-c",config.workingDirectory);if(config.size)args2.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args2.push(config.shell);if(tmuxExec(args2),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 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"]),config.command)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 assignedPort=port??await findAvailablePort(TTYD_BASE_PORT,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 child=Bun.spawn(["ttyd","-t","fontSize=14","-t",'theme={"background":"#1e1e1e","foreground":"#cccccc"}',"--writable","--port",String(assignedPort),"tmux","attach","-t",tmuxName],{stdout:"ignore",stderr:"ignore",stdin:"ignore",env:ttydEnv});if(!child.pid)throw Error("Failed to start ttyd \u2014 no PID returned");await sleep(500),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};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)}}}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:"127.0.0.1"})}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 ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;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.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)}};var 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 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(),args2=["new-session","-d","-s",target];if(config.workingDirectory)args2.push("-c",config.workingDirectory);if(config.size)args2.push("-x",String(config.size.cols),"-y",String(config.size.rows));if(config.shell)args2.push(config.shell);if(tmuxExec(args2),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 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"]),config.command)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 assignedPort=port??await findAvailablePort(TTYD_BASE_PORT,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 child=Bun.spawn(["ttyd","-t","fontSize=14","-t",'theme={"background":"#1e1e1e","foreground":"#cccccc"}',"--writable","--port",String(assignedPort),"tmux","attach","-t",tmuxName],{stdout:"ignore",stderr:"ignore",stdin:"ignore",env:ttydEnv});if(!child.pid)throw Error("Failed to start ttyd \u2014 no PID returned");await sleep(500),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};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(`
4
4
  `).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(`
5
5
  `)){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=Bun.which("ttyd")!==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
6
  `),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(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecontrols/vibe-plugin-session-tmux",
3
- "version": "2026.509.3",
3
+ "version": "2026.510.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "description": "Tmux + ttyd session provider plugin for VibeControls Agent",
@@ -27,7 +27,7 @@
27
27
  "sanity": "bun run format:check && bun run lint:sanity && bun run type:check && bun run build"
28
28
  },
29
29
  "dependencies": {
30
- "@vibecontrols/plugin-sdk": "2026.509.1",
30
+ "@vibecontrols/plugin-sdk": "2026.509.2",
31
31
  "elysia": "^1.4.28"
32
32
  },
33
33
  "devDependencies": {