shared.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. /**
  2. * Shared multiplexer infrastructure
  3. *
  4. * Functions used across tmux, zellij, and herdr backend adapters.
  5. * Extracted to eliminate copy-paste duplication and prevent drift.
  6. */
  7. import { crossSpawn } from '../utils/compat';
  8. import { log } from '../utils/logger';
  9. export function quoteShellArg(value: string): string {
  10. return `'${value.replace(/'/g, `'\\''`)}'`;
  11. }
  12. export function buildOpencodeAttachCommand(
  13. sessionId: string,
  14. serverUrl: string,
  15. directory: string,
  16. ): string {
  17. return [
  18. 'opencode',
  19. 'attach',
  20. quoteShellArg(serverUrl),
  21. '--session',
  22. quoteShellArg(sessionId),
  23. '--dir',
  24. quoteShellArg(directory),
  25. ].join(' ');
  26. }
  27. export async function findBinary(
  28. binaryName: string,
  29. options: { verify?: boolean } = {},
  30. ): Promise<string | null> {
  31. const isWindows = process.platform === 'win32';
  32. const cmd = isWindows ? 'where' : 'which';
  33. const logPrefix = `[${binaryName}]`;
  34. try {
  35. const proc = crossSpawn([cmd, binaryName], {
  36. stdout: 'pipe',
  37. stderr: 'pipe',
  38. });
  39. const exitCode = await proc.exited;
  40. if (exitCode !== 0) {
  41. log(`${logPrefix} findBinary: '${cmd} ${binaryName}' failed`, {
  42. exitCode,
  43. });
  44. return null;
  45. }
  46. const stdout = await proc.stdout();
  47. const path = stdout.trim().split('\n')[0];
  48. if (!path) {
  49. log(`${logPrefix} findBinary: no path in output`);
  50. return null;
  51. }
  52. log(`${logPrefix} findBinary: found`, { path });
  53. // Verify the binary works if requested
  54. if (options.verify) {
  55. try {
  56. const verifyProc = crossSpawn([path, '-V'], {
  57. stdout: 'pipe',
  58. stderr: 'pipe',
  59. });
  60. const verifyExitCode = await verifyProc.exited;
  61. if (verifyExitCode !== 0) {
  62. log(`${logPrefix} findBinary: verification failed for ${path}`);
  63. return null;
  64. }
  65. const verifyStdout = await verifyProc.stdout();
  66. log(`${logPrefix} findBinary: verified`, {
  67. version: verifyStdout.trim(),
  68. });
  69. } catch (verifyErr) {
  70. log(`${logPrefix} findBinary: verification exception`, {
  71. error: String(verifyErr),
  72. });
  73. return null;
  74. }
  75. }
  76. return path;
  77. } catch (err) {
  78. log(`${logPrefix} findBinary: exception`, { error: String(err) });
  79. return null;
  80. }
  81. }
  82. const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
  83. export interface GracefulClosePaneOptions {
  84. /** Backend-specific Ctrl+C command args (binary prepended by caller). */
  85. ctrlC: string[];
  86. /** Backend-specific close/kill command args (binary prepended by caller). */
  87. close: string[];
  88. /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
  89. acceptExitCode1?: boolean;
  90. /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
  91. emptyPaneReturnsTrue?: boolean;
  92. }
  93. export async function gracefulClosePane(
  94. binary: string | null,
  95. paneId: string,
  96. options: GracefulClosePaneOptions,
  97. ): Promise<boolean> {
  98. if (!binary) return false;
  99. const isEmpty = !paneId || paneId === 'unknown';
  100. if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
  101. try {
  102. const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
  103. stdout: 'ignore',
  104. stderr: 'ignore',
  105. });
  106. await ctrlCProc.exited;
  107. await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
  108. const proc = crossSpawn([binary, ...options.close], {
  109. stdout: 'ignore',
  110. stderr: 'ignore',
  111. });
  112. const exitCode = await proc.exited;
  113. if (exitCode === 0) return true;
  114. if (options.acceptExitCode1 && exitCode === 1) return true;
  115. return false;
  116. } catch {
  117. return false;
  118. }
  119. }