session-manager.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. import type { PluginInput } from '@opencode-ai/plugin';
  2. import { POLL_INTERVAL_BACKGROUND_MS } from '../config';
  3. import type { MultiplexerConfig } from '../config/schema';
  4. import {
  5. getMultiplexer,
  6. isServerRunning,
  7. type Multiplexer,
  8. } from '../multiplexer';
  9. import type { BackgroundJobState } from '../utils/background-job-board';
  10. import type { BackgroundJobStore } from '../utils/background-job-store';
  11. import { log } from '../utils/logger';
  12. import {
  13. CmuxSessionLifecycle,
  14. type CmuxSessionLifecycleOptions,
  15. } from './cmux/session-lifecycle';
  16. import { CmuxSessionStore } from './cmux/session-state';
  17. type BackgroundJobReader = Pick<
  18. BackgroundJobStore,
  19. 'getState' | 'deferIfRunning' | 'clearDeferredClose'
  20. >;
  21. interface TrackedSession {
  22. sessionId: string;
  23. paneId: string;
  24. parentId: string;
  25. title: string;
  26. directory: string;
  27. ownerInstanceId: string;
  28. }
  29. interface KnownSession {
  30. parentId: string;
  31. title: string;
  32. directory: string;
  33. }
  34. interface SharedSessionState {
  35. sessions: Map<string, TrackedSession>;
  36. knownSessions: Map<string, KnownSession>;
  37. spawningSessions: Set<string>;
  38. closingSessions: Map<string, Promise<void>>;
  39. }
  40. interface SessionEvent {
  41. type: string;
  42. properties?: {
  43. info?: {
  44. id?: string;
  45. parentID?: string;
  46. title?: string;
  47. directory?: string;
  48. sessionID?: string;
  49. };
  50. part?: { sessionID?: string };
  51. sessionID?: string;
  52. status?: { type: string };
  53. };
  54. }
  55. type CloseReason = 'idle' | 'deleted';
  56. const SHARED_STATE_KEY = Symbol.for(
  57. 'oh-my-opencode-slim.multiplexer-session-manager.state',
  58. );
  59. function getSharedState(): SharedSessionState {
  60. const globalWithState = globalThis as typeof globalThis & {
  61. [SHARED_STATE_KEY]?: SharedSessionState;
  62. };
  63. globalWithState[SHARED_STATE_KEY] ??= {
  64. sessions: new Map(),
  65. knownSessions: new Map(),
  66. spawningSessions: new Set(),
  67. closingSessions: new Map(),
  68. };
  69. return globalWithState[SHARED_STATE_KEY];
  70. }
  71. export function resetMultiplexerSessionManagerState(): void {
  72. const state = getSharedState();
  73. state.sessions.clear();
  74. state.knownSessions.clear();
  75. state.spawningSessions.clear();
  76. state.closingSessions.clear();
  77. new CmuxSessionStore().resetForTests();
  78. }
  79. export type MultiplexerSessionManagerOptions = CmuxSessionLifecycleOptions;
  80. function validServerUrl(value: unknown): string | null {
  81. if (typeof value !== 'string' && !(value instanceof URL)) return null;
  82. try {
  83. const url = new URL(value.toString());
  84. return url.protocol === 'http:' || url.protocol === 'https:'
  85. ? url.toString()
  86. : null;
  87. } catch {
  88. return null;
  89. }
  90. }
  91. function clientBaseUrl(client: unknown): string | null {
  92. try {
  93. if (!client || typeof client !== 'object' || !('_client' in client))
  94. return null;
  95. const internal = client._client;
  96. if (!internal || typeof internal !== 'object' || !('getConfig' in internal))
  97. return null;
  98. const getConfig = internal.getConfig;
  99. if (typeof getConfig !== 'function') return null;
  100. const config: unknown = getConfig.call(internal);
  101. if (!config || typeof config !== 'object' || !('baseUrl' in config))
  102. return null;
  103. return validServerUrl(config.baseUrl);
  104. } catch {
  105. return null;
  106. }
  107. }
  108. function createServerUrlResolver(ctx: PluginInput): () => string | null {
  109. return () => {
  110. try {
  111. const serverUrl = validServerUrl(ctx.serverUrl);
  112. if (serverUrl) return serverUrl;
  113. } catch {}
  114. try {
  115. return clientBaseUrl(ctx.client);
  116. } catch {
  117. return null;
  118. }
  119. };
  120. }
  121. /**
  122. * Tracks child sessions and spawns/closes multiplexer panes for them.
  123. *
  124. * Uses session.status events for completion detection instead of polling,
  125. * with polling kept as a fallback for reliability.
  126. */
  127. export class MultiplexerSessionManager {
  128. private instanceId = Math.random().toString(36).slice(2, 8);
  129. private readonly resolveServerUrl: () => string | null;
  130. private directory: string;
  131. private multiplexer: Multiplexer | null = null;
  132. private sessions: SharedSessionState['sessions'];
  133. private knownSessions: SharedSessionState['knownSessions'];
  134. private spawningSessions: SharedSessionState['spawningSessions'];
  135. private closingSessions: SharedSessionState['closingSessions'];
  136. private pollInterval?: ReturnType<typeof setInterval>;
  137. private enabled = false;
  138. private cmuxLifecycle?: CmuxSessionLifecycle;
  139. constructor(
  140. ctx: PluginInput,
  141. config: MultiplexerConfig,
  142. private readonly backgroundJobBoard?: BackgroundJobReader,
  143. options: MultiplexerSessionManagerOptions = {},
  144. ) {
  145. const sharedState = getSharedState();
  146. this.sessions = sharedState.sessions;
  147. this.knownSessions = sharedState.knownSessions;
  148. this.spawningSessions = sharedState.spawningSessions;
  149. this.closingSessions = sharedState.closingSessions;
  150. this.directory = ctx.directory;
  151. this.resolveServerUrl = createServerUrlResolver(ctx);
  152. this.multiplexer = getMultiplexer(config);
  153. this.enabled =
  154. config.type !== 'none' &&
  155. this.multiplexer !== null &&
  156. this.multiplexer.isInsideSession();
  157. if (this.enabled && this.multiplexer?.type === 'cmux') {
  158. this.cmuxLifecycle = new CmuxSessionLifecycle(
  159. this.instanceId,
  160. this.multiplexer,
  161. this.resolveServerUrl,
  162. this.directory,
  163. this.backgroundJobBoard,
  164. options,
  165. );
  166. }
  167. log('[multiplexer-session-manager] initialized', {
  168. instanceId: this.instanceId,
  169. enabled: this.enabled,
  170. type: config.type,
  171. serverUrl: 'dynamic',
  172. trackedSessions: this.sessions.size,
  173. knownSessions: this.knownSessions.size,
  174. });
  175. }
  176. async onSessionCreated(event: SessionEvent): Promise<void> {
  177. if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionCreated(event);
  178. if (!this.enabled || !this.multiplexer) return;
  179. if (event.type !== 'session.created') return;
  180. const info = event.properties?.info;
  181. if (!info?.id || !info?.parentID) {
  182. return;
  183. }
  184. const sessionId = info.id;
  185. const parentId = info.parentID;
  186. const title = info.title ?? 'Subagent';
  187. const directory = info.directory ?? this.directory;
  188. if (this.isTrackedOrSpawning(sessionId)) {
  189. log('[multiplexer-session-manager] session already tracked or spawning', {
  190. instanceId: this.instanceId,
  191. sessionId,
  192. });
  193. return;
  194. }
  195. const closing = this.closingSessions.get(sessionId);
  196. if (closing) await closing;
  197. if (this.isTrackedOrSpawning(sessionId)) return;
  198. this.knownSessions.set(sessionId, {
  199. parentId,
  200. title,
  201. directory,
  202. });
  203. this.spawningSessions.add(sessionId);
  204. try {
  205. const serverUrl = this.resolveServerUrl();
  206. if (!serverUrl) {
  207. log(
  208. '[multiplexer-session-manager] no valid server URL, skipping spawn',
  209. {
  210. instanceId: this.instanceId,
  211. sessionId,
  212. },
  213. );
  214. return;
  215. }
  216. const serverRunning = await isServerRunning(serverUrl);
  217. if (!serverRunning) {
  218. log('[multiplexer-session-manager] server not running, skipping', {
  219. instanceId: this.instanceId,
  220. serverUrl,
  221. });
  222. return;
  223. }
  224. if (this.closingSessions.has(sessionId) || this.sessions.has(sessionId)) {
  225. return;
  226. }
  227. log(
  228. '[multiplexer-session-manager] child session created, spawning pane',
  229. {
  230. sessionId,
  231. parentId,
  232. title,
  233. instanceId: this.instanceId,
  234. },
  235. );
  236. const paneResult = await this.multiplexer
  237. .spawnPane(sessionId, title, serverUrl, directory)
  238. .catch((err) => {
  239. log('[multiplexer-session-manager] failed to spawn pane', {
  240. instanceId: this.instanceId,
  241. error: String(err),
  242. });
  243. return { success: false, paneId: undefined };
  244. });
  245. if (!paneResult.success || !paneResult.paneId) return;
  246. if (
  247. !this.knownSessions.has(sessionId) ||
  248. this.closingSessions.has(sessionId)
  249. ) {
  250. await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
  251. log(
  252. '[multiplexer-session-manager] closing stale spawned pane failed',
  253. {
  254. sessionId,
  255. paneId: paneResult.paneId,
  256. instanceId: this.instanceId,
  257. error: String(err),
  258. },
  259. ),
  260. );
  261. return;
  262. }
  263. this.sessions.set(sessionId, {
  264. sessionId,
  265. paneId: paneResult.paneId,
  266. parentId,
  267. title,
  268. directory,
  269. ownerInstanceId: this.instanceId,
  270. });
  271. log('[multiplexer-session-manager] pane spawned', {
  272. instanceId: this.instanceId,
  273. sessionId,
  274. paneId: paneResult.paneId,
  275. });
  276. this.startPolling();
  277. } finally {
  278. this.spawningSessions.delete(sessionId);
  279. }
  280. }
  281. async onSessionStatus(event: SessionEvent): Promise<void> {
  282. if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionStatus(event);
  283. if (!this.enabled) return;
  284. if (event.type === 'session.idle') {
  285. const sessionId = event.properties?.sessionID;
  286. if (!sessionId) return;
  287. log('[multiplexer-session-manager] session idle event received', {
  288. instanceId: this.instanceId,
  289. sessionId,
  290. tracked: this.sessions.has(sessionId),
  291. known: this.knownSessions.has(sessionId),
  292. ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
  293. backgroundJobState: this.backgroundJobState(sessionId),
  294. });
  295. await this.closeSession(sessionId, 'idle');
  296. return;
  297. }
  298. if (event.type !== 'session.status') return;
  299. const sessionId = event.properties?.sessionID;
  300. if (!sessionId) return;
  301. const statusType = event.properties?.status?.type;
  302. if (statusType === 'idle') {
  303. log('[multiplexer-session-manager] session status idle received', {
  304. instanceId: this.instanceId,
  305. sessionId,
  306. tracked: this.sessions.has(sessionId),
  307. known: this.knownSessions.has(sessionId),
  308. ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
  309. backgroundJobState: this.backgroundJobState(sessionId),
  310. });
  311. await this.closeSession(sessionId, 'idle');
  312. return;
  313. }
  314. if (statusType) {
  315. if (statusType !== 'busy') {
  316. this.backgroundJobBoard?.clearDeferredClose(sessionId);
  317. return;
  318. }
  319. log('[multiplexer-session-manager] session busy event received', {
  320. instanceId: this.instanceId,
  321. sessionId,
  322. tracked: this.sessions.has(sessionId),
  323. known: this.knownSessions.has(sessionId),
  324. ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
  325. backgroundJobState: this.backgroundJobState(sessionId),
  326. });
  327. await this.respawnIfKnown(sessionId);
  328. }
  329. }
  330. async onSessionDeleted(event: SessionEvent): Promise<void> {
  331. if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionDeleted(event);
  332. if (!this.enabled) return;
  333. if (event.type !== 'session.deleted') return;
  334. const sessionId = this.getSessionId(event);
  335. if (!sessionId) return;
  336. log('[multiplexer-session-manager] session deleted, closing pane', {
  337. instanceId: this.instanceId,
  338. sessionId,
  339. tracked: this.sessions.has(sessionId),
  340. known: this.knownSessions.has(sessionId),
  341. ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
  342. backgroundJobState: this.backgroundJobState(sessionId),
  343. });
  344. await this.closeSession(sessionId, 'deleted');
  345. }
  346. private startPolling(): void {
  347. if (this.pollInterval) return;
  348. this.pollInterval = setInterval(
  349. () => this.pollSessions(),
  350. POLL_INTERVAL_BACKGROUND_MS,
  351. );
  352. log('[multiplexer-session-manager] polling started', {
  353. instanceId: this.instanceId,
  354. });
  355. }
  356. private stopPolling(): void {
  357. if (this.pollInterval) {
  358. clearInterval(this.pollInterval);
  359. this.pollInterval = undefined;
  360. log('[multiplexer-session-manager] polling stopped', {
  361. instanceId: this.instanceId,
  362. });
  363. }
  364. }
  365. private async pollSessions(): Promise<void> {
  366. if (this.cmuxLifecycle) return this.cmuxLifecycle.pollOnce();
  367. if (this.sessions.size === 0) {
  368. this.stopPolling();
  369. return;
  370. }
  371. try {
  372. const allStatuses = await this.fetchSessionStatuses();
  373. const sessionsToClose: string[] = [];
  374. for (const [sessionId, tracked] of this.sessions.entries()) {
  375. if (tracked.ownerInstanceId !== this.instanceId) {
  376. log('[multiplexer-session-manager] skipping non-owner poll close', {
  377. instanceId: this.instanceId,
  378. ownerInstanceId: tracked.ownerInstanceId,
  379. sessionId,
  380. paneId: tracked.paneId,
  381. });
  382. continue;
  383. }
  384. const status = allStatuses[sessionId];
  385. if (!status) continue;
  386. if (status.type !== 'idle') {
  387. this.backgroundJobBoard?.clearDeferredClose(sessionId);
  388. continue;
  389. }
  390. sessionsToClose.push(sessionId);
  391. }
  392. for (const sessionId of sessionsToClose) {
  393. await this.closeSession(sessionId, 'idle');
  394. }
  395. } catch (err) {
  396. log('[multiplexer-session-manager] poll error', { error: String(err) });
  397. }
  398. }
  399. private async fetchSessionStatuses(): Promise<
  400. Record<string, { type: string }>
  401. > {
  402. const serverUrl = this.resolveServerUrl();
  403. if (!serverUrl) {
  404. log('[multiplexer-session-manager] no valid server URL, skipping poll', {
  405. instanceId: this.instanceId,
  406. });
  407. return {};
  408. }
  409. const url = new URL('/session/status', serverUrl);
  410. const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
  411. if (!response.ok) {
  412. throw new Error(
  413. `session status request failed: ${response.status} ${response.statusText}`,
  414. );
  415. }
  416. const body = await response.text();
  417. if (body.trim() === '') {
  418. throw new Error('session status response was empty');
  419. }
  420. try {
  421. return JSON.parse(body) as Record<string, { type: string }>;
  422. } catch (err) {
  423. throw new Error(`session status response was not valid JSON: ${err}`);
  424. }
  425. }
  426. private async closeSession(
  427. sessionId: string,
  428. reason: CloseReason,
  429. skipPolicyCheck = false,
  430. ): Promise<void> {
  431. if (reason === 'deleted') {
  432. this.knownSessions.delete(sessionId);
  433. this.backgroundJobBoard?.clearDeferredClose(sessionId);
  434. }
  435. const existingClose = this.closingSessions.get(sessionId);
  436. if (existingClose) return existingClose;
  437. const tracked = this.sessions.get(sessionId);
  438. if (!tracked || !this.multiplexer) {
  439. log('[multiplexer-session-manager] close skipped; session not tracked', {
  440. instanceId: this.instanceId,
  441. sessionId,
  442. reason,
  443. tracked: !!tracked,
  444. hasMultiplexer: !!this.multiplexer,
  445. });
  446. return;
  447. }
  448. if (reason !== 'deleted' && tracked.ownerInstanceId !== this.instanceId) {
  449. log('[multiplexer-session-manager] close skipped; non-owner instance', {
  450. instanceId: this.instanceId,
  451. ownerInstanceId: tracked.ownerInstanceId,
  452. sessionId,
  453. paneId: tracked.paneId,
  454. reason,
  455. });
  456. return;
  457. }
  458. if (reason === 'deleted' && tracked.ownerInstanceId !== this.instanceId) {
  459. log('[multiplexer-session-manager] closing deleted pane as non-owner', {
  460. instanceId: this.instanceId,
  461. ownerInstanceId: tracked.ownerInstanceId,
  462. sessionId,
  463. paneId: tracked.paneId,
  464. reason,
  465. });
  466. }
  467. if (
  468. reason === 'idle' &&
  469. !skipPolicyCheck &&
  470. !this.shouldCloseNow(sessionId)
  471. ) {
  472. log(
  473. '[multiplexer-session-manager] close skipped; background job running',
  474. {
  475. instanceId: this.instanceId,
  476. sessionId,
  477. paneId: tracked.paneId,
  478. reason,
  479. backgroundJobState: this.backgroundJobState(sessionId),
  480. },
  481. );
  482. return;
  483. }
  484. this.sessions.delete(sessionId);
  485. log('[multiplexer-session-manager] closing session pane', {
  486. instanceId: this.instanceId,
  487. sessionId,
  488. paneId: tracked.paneId,
  489. reason,
  490. backgroundJobState: this.backgroundJobState(sessionId),
  491. parentId: tracked.parentId,
  492. title: tracked.title,
  493. });
  494. const closePromise: Promise<void> = this.multiplexer
  495. .closePane(tracked.paneId)
  496. .then(() => undefined)
  497. .catch((err) =>
  498. log('[multiplexer-session-manager] failed to close session pane', {
  499. instanceId: this.instanceId,
  500. sessionId,
  501. paneId: tracked.paneId,
  502. reason,
  503. error: String(err),
  504. }),
  505. )
  506. .finally(() => {
  507. this.closingSessions.delete(sessionId);
  508. this.updatePolling();
  509. });
  510. this.closingSessions.set(sessionId, closePromise);
  511. await closePromise;
  512. }
  513. private async respawnIfKnown(sessionId: string): Promise<void> {
  514. if (!this.enabled || !this.multiplexer) return;
  515. const closing = this.closingSessions.get(sessionId);
  516. if (closing) await closing;
  517. if (this.isTrackedOrSpawning(sessionId)) {
  518. return;
  519. }
  520. const known = this.knownSessions.get(sessionId);
  521. if (!known) return;
  522. this.spawningSessions.add(sessionId);
  523. try {
  524. const serverUrl = this.resolveServerUrl();
  525. if (!serverUrl) {
  526. log(
  527. '[multiplexer-session-manager] no valid server URL, skipping respawn',
  528. {
  529. instanceId: this.instanceId,
  530. sessionId,
  531. },
  532. );
  533. return;
  534. }
  535. const serverRunning = await isServerRunning(serverUrl);
  536. if (!serverRunning) {
  537. log(
  538. '[multiplexer-session-manager] server not running, skipping busy respawn',
  539. {
  540. instanceId: this.instanceId,
  541. serverUrl,
  542. sessionId,
  543. },
  544. );
  545. return;
  546. }
  547. if (this.sessions.has(sessionId) || this.closingSessions.has(sessionId)) {
  548. return;
  549. }
  550. log(
  551. '[multiplexer-session-manager] child session busy again, respawning pane',
  552. {
  553. instanceId: this.instanceId,
  554. sessionId,
  555. parentId: known.parentId,
  556. title: known.title,
  557. },
  558. );
  559. const paneResult = await this.multiplexer
  560. .spawnPane(sessionId, known.title, serverUrl, known.directory)
  561. .catch((err) => {
  562. log('[multiplexer-session-manager] failed to respawn pane', {
  563. instanceId: this.instanceId,
  564. error: String(err),
  565. });
  566. return { success: false, paneId: undefined };
  567. });
  568. if (!paneResult.success || !paneResult.paneId) return;
  569. if (
  570. !this.knownSessions.has(sessionId) ||
  571. this.closingSessions.has(sessionId)
  572. ) {
  573. await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
  574. log(
  575. '[multiplexer-session-manager] closing stale respawned pane failed',
  576. {
  577. instanceId: this.instanceId,
  578. sessionId,
  579. paneId: paneResult.paneId,
  580. error: String(err),
  581. },
  582. ),
  583. );
  584. return;
  585. }
  586. this.sessions.set(sessionId, {
  587. sessionId,
  588. paneId: paneResult.paneId,
  589. parentId: known.parentId,
  590. title: known.title,
  591. directory: known.directory,
  592. ownerInstanceId: this.instanceId,
  593. });
  594. this.backgroundJobBoard?.clearDeferredClose(sessionId);
  595. log('[multiplexer-session-manager] pane respawned on busy', {
  596. instanceId: this.instanceId,
  597. sessionId,
  598. paneId: paneResult.paneId,
  599. });
  600. this.startPolling();
  601. } finally {
  602. this.spawningSessions.delete(sessionId);
  603. }
  604. }
  605. private isTrackedOrSpawning(sessionId: string): boolean {
  606. return this.sessions.has(sessionId) || this.spawningSessions.has(sessionId);
  607. }
  608. private updatePolling(): void {
  609. if (this.sessions.size > 0 || this.closingSessions.size > 0) {
  610. this.startPolling();
  611. } else {
  612. this.stopPolling();
  613. }
  614. }
  615. private getSessionId(event: SessionEvent): string | undefined {
  616. return event.properties?.info?.id || event.properties?.sessionID;
  617. }
  618. private backgroundJobState(
  619. sessionId: string,
  620. ): BackgroundJobState | undefined {
  621. return this.backgroundJobBoard?.getState(sessionId);
  622. }
  623. private shouldCloseNow(sessionId: string): boolean {
  624. return this.backgroundJobBoard?.deferIfRunning(sessionId) ?? true;
  625. }
  626. async closeSessionFromCoordinator(sessionId: string): Promise<void> {
  627. if (this.cmuxLifecycle)
  628. return this.cmuxLifecycle.closeSessionFromCoordinator(sessionId);
  629. if (!this.enabled) return;
  630. // Coordinator already vetted lifecycle policy; skip re-check
  631. // ponytail: theoretical race if new job starts between coordinator's
  632. // retryDeferredClose() and this call, but session IDs are unique per launch
  633. await this.closeSession(sessionId, 'idle', true);
  634. }
  635. async cleanup(): Promise<void> {
  636. if (this.cmuxLifecycle) return this.cmuxLifecycle.cleanup();
  637. this.stopPolling();
  638. if (this.closingSessions.size > 0) {
  639. await Promise.all(this.closingSessions.values());
  640. }
  641. if (this.sessions.size > 0 && this.multiplexer) {
  642. log('[multiplexer-session-manager] closing all panes', {
  643. count: this.sessions.size,
  644. });
  645. const multiplexer = this.multiplexer;
  646. const closePromises = Array.from(this.sessions.values()).map((s) =>
  647. multiplexer.closePane(s.paneId).catch((err) =>
  648. log('[multiplexer-session-manager] cleanup error for pane', {
  649. paneId: s.paneId,
  650. error: String(err),
  651. }),
  652. ),
  653. );
  654. await Promise.all(closePromises);
  655. this.sessions.clear();
  656. }
  657. this.knownSessions.clear();
  658. this.spawningSessions.clear();
  659. this.closingSessions.clear();
  660. // ponytail: deferred state lives in coordinator, not here
  661. // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
  662. log('[multiplexer-session-manager] cleanup complete');
  663. }
  664. async cleanupOnInstanceDisposed(): Promise<void> {
  665. if (this.cmuxLifecycle) await this.cmuxLifecycle.cleanup();
  666. }
  667. }
  668. /**
  669. * @deprecated Use MultiplexerSessionManager instead
  670. */
  671. export const TmuxSessionManager = MultiplexerSessionManager;