@vibecontrols/vibe-plugin-session-tmux 2026.509.1 → 2026.509.3
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.
Potentially problematic release.
This version of @vibecontrols/vibe-plugin-session-tmux might be problematic. Click here for more details.
- package/dist/index.d.ts +13 -53
- package/dist/index.js +5 -4
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* Tmux + ttyd session provider plugin for VibeControls Agent.
|
|
5
5
|
* Implements the full SessionProvider interface (23 methods) using tmux for
|
|
6
6
|
* terminal session management and ttyd for browser-accessible web terminals.
|
|
7
|
+
*
|
|
8
|
+
* Migrated to consume @vibecontrols/plugin-sdk@2026.509.1 — inline contract
|
|
9
|
+
* stubs and subprocess/storage helpers replaced by SDK imports.
|
|
7
10
|
*/
|
|
11
|
+
import type { VibePluginFactory } from "@vibecontrols/plugin-sdk/contract";
|
|
8
12
|
type SessionStatus = "active" | "inactive" | "terminated" | "error";
|
|
9
13
|
interface SessionConfig {
|
|
10
14
|
/** Optional external ID (e.g. backend UUID). If provided the plugin uses it instead of generating one. */
|
|
@@ -135,56 +139,12 @@ interface SessionProviderCapabilities {
|
|
|
135
139
|
};
|
|
136
140
|
platform: string[];
|
|
137
141
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
delete(namespace: string, key: string): Promise<boolean>;
|
|
148
|
-
}
|
|
149
|
-
interface HostServices {
|
|
150
|
-
telemetry?: {
|
|
151
|
-
emit: (name: string, payload?: Record<string, unknown>) => void;
|
|
152
|
-
};
|
|
153
|
-
logger: HostLogger;
|
|
154
|
-
storage: HostStorage;
|
|
155
|
-
}
|
|
156
|
-
interface PluginCapabilities {
|
|
157
|
-
storage?: "none" | "read" | "rw";
|
|
158
|
-
secrets?: "none" | "read" | "rw";
|
|
159
|
-
gateway?: boolean;
|
|
160
|
-
broadcast?: boolean;
|
|
161
|
-
subprocess?: boolean;
|
|
162
|
-
audit?: boolean;
|
|
163
|
-
telemetry?: boolean;
|
|
164
|
-
}
|
|
165
|
-
interface VibePlugin {
|
|
166
|
-
capabilities?: PluginCapabilities;
|
|
167
|
-
name: string;
|
|
168
|
-
version: string;
|
|
169
|
-
description: string;
|
|
170
|
-
tags?: Array<"backend" | "frontend" | "cli" | "provider" | "adapter" | "integration">;
|
|
171
|
-
apiPrefix?: string;
|
|
172
|
-
prerequisites?: Array<{
|
|
173
|
-
name: string;
|
|
174
|
-
kind: "binary" | "npm" | "pip" | "cargo" | "manual";
|
|
175
|
-
requiresSudo: boolean;
|
|
176
|
-
description?: string;
|
|
177
|
-
}>;
|
|
178
|
-
providers: {
|
|
179
|
-
session?: SessionProvider;
|
|
180
|
-
};
|
|
181
|
-
createRoutes?(deps?: unknown): any;
|
|
182
|
-
onServerStart?(_app: unknown, services: HostServices): Promise<void>;
|
|
183
|
-
onServerStop?(context?: {
|
|
184
|
-
reason: "reload" | "shutdown";
|
|
185
|
-
}): Promise<void>;
|
|
186
|
-
onCliSetup?(): void;
|
|
187
|
-
}
|
|
188
|
-
declare const vibePlugin: VibePlugin;
|
|
189
|
-
export { vibePlugin };
|
|
190
|
-
export type { SessionProvider, SessionProviderCapabilities, SessionConfig, SessionInfo, SessionStatus, TerminalInfo, HealthCheckResult, SystemSessionInfo, SystemTerminalInfo, VibePlugin, HostServices, };
|
|
142
|
+
/**
|
|
143
|
+
* Plugin contract V2 factory. The agent calls this once per profile at
|
|
144
|
+
* load time. Lifecycle hooks and the VibePlugin object are constructed
|
|
145
|
+
* fresh per-call so that any per-profile context can be captured if
|
|
146
|
+
* needed in the future. The underlying provider is a process-wide
|
|
147
|
+
* singleton (see comment above) since tmux/ttyd PIDs are global.
|
|
148
|
+
*/
|
|
149
|
+
export declare const createPlugin: VibePluginFactory;
|
|
150
|
+
export type { SessionProvider, SessionProviderCapabilities, SessionConfig, SessionInfo, SessionStatus, TerminalInfo, HealthCheckResult, SystemSessionInfo, SystemTerminalInfo, };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{Elysia}from"elysia";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}}async function findAvailablePort(start){for(let port=start;port<start+200;port++)if(await isPortAvailable(port))return port;throw Error(`No available port found in range ${start}-${start+200-1}`)}function isPortAvailable(port){return new Promise((resolve)=>{try{Bun.listen({hostname:"127.0.0.1",port,socket:{data(){}}}).stop(!0),resolve(!0)}catch{resolve(!1)}})}async function gracefulKill(pid,timeout=3000){try{process.kill(pid,"SIGTERM")}catch{return}let deadline=Date.now()+timeout;while(Date.now()<deadline)if(await sleep(200),!isProcessAlive(pid))return;try{process.kill(pid,"SIGKILL")}catch{}}function isProcessAlive(pid){try{return process.kill(pid,0),!0}catch{return!1}}function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}function nowISO(){return new Date().toISOString()}class TmuxSessionProvider{name="session-tmux";services=null;log={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};storage=(()=>{let mem=new Map;return{get:async(namespace,key)=>mem.get(`${namespace}:${key}`)??null,set:async(namespace,key,value)=>{mem.set(`${namespace}:${key}`,value)},delete:async(namespace,key)=>{return mem.delete(`${namespace}:${key}`)}}})();ttydPids=new Map;ttydPorts=new Map;async init(services){if(this.services=services,services.logger)this.log=services.logger;if(services.storage)this.storage=services.storage;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.storage.delete("session-tmux","terminals")}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(7681);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(`
|
|
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
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(`
|
|
4
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(`
|
|
5
|
-
`),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(){
|
|
6
|
-
`)[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}}async persistTerminals(){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.
|
|
7
|
-
`)){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},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)??null}function createPrereqsRoutes(){let checks=["tmux","ttyd"],installCmds={tmux:process.platform==="darwin"?"brew install tmux":"sudo apt-get install -y tmux",ttyd:process.platform==="darwin"?"brew install ttyd":"sudo apt-get install -y ttyd"};return new Elysia({prefix:"/prereqs"}).get("/status",()=>{let missing=checks.filter((bin)=>!whichSync(bin)).map((name)=>({name,kind:"binary",requiresSudo:!0}));return{satisfied:missing.length===0,missing}}).post("/install",()=>{let pendingSudo=checks.filter((bin)=>!whichSync(bin)).map((name)=>({name,command:installCmds[name]??`(see install docs for ${name})`,reason:`${name} is required for tmux session backend.`}));return{ok:!0,installed:[],pendingSudo,errors:[]}}).post("/uninstall",()=>({ok:!0}))}var
|
|
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(`
|
|
7
|
+
`)[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}}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},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(`
|
|
8
|
+
`)){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},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)??null}function createPrereqsRoutes(){let checks=["tmux","ttyd"],installCmds={tmux:process.platform==="darwin"?"brew install tmux":"sudo apt-get install -y tmux",ttyd:process.platform==="darwin"?"brew install ttyd":"sudo apt-get install -y ttyd"};return new Elysia({prefix:"/prereqs"}).get("/status",()=>{let missing=checks.filter((bin)=>!whichSync(bin)).map((name)=>({name,kind:"binary",requiresSudo:!0}));return{satisfied:missing.length===0,missing}}).post("/install",()=>{let pendingSudo=checks.filter((bin)=>!whichSync(bin)).map((name)=>({name,command:installCmds[name]??`(see install docs for ${name})`,reason:`${name} is required for tmux session backend.`}));return{ok:!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"})}});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:!0}],createRoutes:()=>createPrereqsRoutes(),onServerStart:lifecycle.onServerStart,onServerStop:lifecycle.onServerStop}};export{createPlugin};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecontrols/vibe-plugin-session-tmux",
|
|
3
|
-
"version": "2026.509.
|
|
3
|
+
"version": "2026.509.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"description": "Tmux + ttyd session provider plugin for VibeControls Agent",
|
|
@@ -27,6 +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
31
|
"elysia": "^1.4.28"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|