tmux-session-manager.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import type { PluginInput } from "@opencode-ai/plugin";
  2. import { spawnTmuxPane, closeTmuxPane, isInsideTmux } from "../utils/tmux";
  3. import type { TmuxConfig } from "../config/schema";
  4. import { log } from "../utils/logger";
  5. import { POLL_INTERVAL_BACKGROUND_MS } from "../config";
  6. type OpencodeClient = PluginInput["client"];
  7. interface TrackedSession {
  8. sessionId: string;
  9. paneId: string;
  10. parentId: string;
  11. title: string;
  12. createdAt: number;
  13. lastSeenAt: number;
  14. missingSince?: number;
  15. }
  16. /**
  17. * Event shape for session creation hooks
  18. */
  19. interface SessionCreatedEvent {
  20. type: string;
  21. properties?: { info?: { id?: string; parentID?: string; title?: string } };
  22. }
  23. const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
  24. const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
  25. /**
  26. * TmuxSessionManager tracks child sessions (created by OpenCode's Task tool)
  27. * and spawns/closes tmux panes for them.
  28. */
  29. export class TmuxSessionManager {
  30. private client: OpencodeClient;
  31. private tmuxConfig: TmuxConfig;
  32. private serverUrl: string;
  33. private sessions = new Map<string, TrackedSession>();
  34. private pollInterval?: ReturnType<typeof setInterval>;
  35. private enabled = false;
  36. constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
  37. this.client = ctx.client;
  38. this.tmuxConfig = tmuxConfig;
  39. this.serverUrl = ctx.serverUrl?.toString() ?? "http://localhost:4096";
  40. this.enabled = tmuxConfig.enabled && isInsideTmux();
  41. log("[tmux-session-manager] initialized", {
  42. enabled: this.enabled,
  43. tmuxConfig: this.tmuxConfig,
  44. serverUrl: this.serverUrl,
  45. });
  46. }
  47. /**
  48. * Handle session.created events.
  49. * Spawns a tmux pane for child sessions (those with parentID).
  50. */
  51. async onSessionCreated(event: {
  52. type: string;
  53. properties?: { info?: { id?: string; parentID?: string; title?: string } };
  54. }): Promise<void> {
  55. if (!this.enabled) return;
  56. if (event.type !== "session.created") return;
  57. const info = event.properties?.info;
  58. if (!info?.id || !info?.parentID) {
  59. // Not a child session, skip
  60. return;
  61. }
  62. const sessionId = info.id;
  63. const parentId = info.parentID;
  64. const title = info.title ?? "Subagent";
  65. // Skip if we're already tracking this session
  66. if (this.sessions.has(sessionId)) {
  67. log("[tmux-session-manager] session already tracked", { sessionId });
  68. return;
  69. }
  70. log("[tmux-session-manager] child session created, spawning pane", {
  71. sessionId,
  72. parentId,
  73. title,
  74. });
  75. const paneResult = await spawnTmuxPane(
  76. sessionId,
  77. title,
  78. this.tmuxConfig,
  79. this.serverUrl
  80. ).catch((err) => {
  81. log("[tmux-session-manager] failed to spawn pane", { error: String(err) });
  82. return { success: false, paneId: undefined };
  83. });
  84. if (paneResult.success && paneResult.paneId) {
  85. const now = Date.now();
  86. this.sessions.set(sessionId, {
  87. sessionId,
  88. paneId: paneResult.paneId,
  89. parentId,
  90. title,
  91. createdAt: now,
  92. lastSeenAt: now,
  93. });
  94. log("[tmux-session-manager] pane spawned", {
  95. sessionId,
  96. paneId: paneResult.paneId,
  97. });
  98. this.startPolling();
  99. }
  100. }
  101. private startPolling(): void {
  102. if (this.pollInterval) return;
  103. this.pollInterval = setInterval(() => this.pollSessions(), POLL_INTERVAL_BACKGROUND_MS);
  104. log("[tmux-session-manager] polling started");
  105. }
  106. private stopPolling(): void {
  107. if (this.pollInterval) {
  108. clearInterval(this.pollInterval);
  109. this.pollInterval = undefined;
  110. log("[tmux-session-manager] polling stopped");
  111. }
  112. }
  113. private async pollSessions(): Promise<void> {
  114. if (this.sessions.size === 0) {
  115. this.stopPolling();
  116. return;
  117. }
  118. try {
  119. const statusResult = await this.client.session.status();
  120. const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
  121. const now = Date.now();
  122. const sessionsToClose: string[] = [];
  123. for (const [sessionId, tracked] of this.sessions.entries()) {
  124. const status = allStatuses[sessionId];
  125. // Session is idle (completed).
  126. const isIdle = status?.type === "idle";
  127. if (status) {
  128. tracked.lastSeenAt = now;
  129. tracked.missingSince = undefined;
  130. } else if (!tracked.missingSince) {
  131. tracked.missingSince = now;
  132. }
  133. const missingTooLong = !!tracked.missingSince
  134. && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
  135. // Check for timeout as a safety fallback
  136. const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
  137. if (isIdle || missingTooLong || isTimedOut) {
  138. sessionsToClose.push(sessionId);
  139. }
  140. }
  141. for (const sessionId of sessionsToClose) {
  142. await this.closeSession(sessionId);
  143. }
  144. } catch (err) {
  145. log("[tmux-session-manager] poll error", { error: String(err) });
  146. }
  147. }
  148. private async closeSession(sessionId: string): Promise<void> {
  149. const tracked = this.sessions.get(sessionId);
  150. if (!tracked) return;
  151. log("[tmux-session-manager] closing session pane", {
  152. sessionId,
  153. paneId: tracked.paneId,
  154. });
  155. await closeTmuxPane(tracked.paneId);
  156. this.sessions.delete(sessionId);
  157. if (this.sessions.size === 0) {
  158. this.stopPolling();
  159. }
  160. }
  161. /**
  162. * Create the event handler for the plugin's event hook.
  163. */
  164. createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
  165. return async (input) => {
  166. await this.onSessionCreated(input.event as SessionCreatedEvent);
  167. };
  168. }
  169. /**
  170. * Clean up all tracked sessions.
  171. */
  172. async cleanup(): Promise<void> {
  173. this.stopPolling();
  174. if (this.sessions.size > 0) {
  175. log("[tmux-session-manager] closing all panes", { count: this.sessions.size });
  176. const closePromises = Array.from(this.sessions.values()).map(s =>
  177. closeTmuxPane(s.paneId).catch(err =>
  178. log("[tmux-session-manager] cleanup error for pane", { paneId: s.paneId, error: String(err) })
  179. )
  180. );
  181. await Promise.all(closePromises);
  182. this.sessions.clear();
  183. }
  184. log("[tmux-session-manager] cleanup complete");
  185. }
  186. }