shared.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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 { existsSync } from 'node:fs';
  8. import { basename, isAbsolute } from 'node:path';
  9. import { crossSpawn } from '../utils/compat';
  10. import { log } from '../utils/logger';
  11. export function quoteShellArg(value: string): string {
  12. return `'${value.replace(/'/g, `'\\''`)}'`;
  13. }
  14. /** Normalize Windows backslashes to / so sh -lc (MSYS2/Git Bash) doesn't treat them as escape chars. */
  15. export function normalizePathForShell(directory: string): string {
  16. return process.platform === 'win32'
  17. ? directory.replace(/\\/g, '/')
  18. : directory;
  19. }
  20. export function buildOpencodeAttachCommand(
  21. sessionId: string,
  22. serverUrl: string,
  23. directory: string,
  24. executable = 'opencode',
  25. ): string {
  26. const attachDir = normalizePathForShell(directory);
  27. return [
  28. executable === 'opencode' ? executable : quoteShellArg(executable),
  29. 'attach',
  30. quoteShellArg(serverUrl),
  31. '--session',
  32. quoteShellArg(sessionId),
  33. '--dir',
  34. quoteShellArg(attachDir),
  35. ].join(' ');
  36. }
  37. export function resolveHostOpencodeBinary(
  38. options: {
  39. override?: string;
  40. envOverride?: string;
  41. execPath?: string;
  42. argv0?: string;
  43. pathExists?: (path: string) => boolean;
  44. } = {},
  45. ): string | null {
  46. const pathExists = options.pathExists ?? existsSync;
  47. for (const candidate of [
  48. options.override,
  49. options.envOverride ?? process.env.OPENCODE_BIN,
  50. options.execPath ?? process.execPath,
  51. options.argv0 ?? process.argv[0],
  52. ]) {
  53. if (
  54. candidate &&
  55. isAbsolute(candidate) &&
  56. /^opencode(?:\.exe)?$/i.test(basename(candidate)) &&
  57. pathExists(candidate)
  58. ) {
  59. return candidate;
  60. }
  61. }
  62. return null;
  63. }
  64. export async function findBinary(
  65. binaryName: string,
  66. options: { verify?: boolean } = {},
  67. ): Promise<string | null> {
  68. const isWindows = process.platform === 'win32';
  69. const cmd = isWindows ? 'where' : 'which';
  70. const logPrefix = `[${binaryName}]`;
  71. try {
  72. const proc = crossSpawn([cmd, binaryName], {
  73. stdout: 'pipe',
  74. stderr: 'pipe',
  75. });
  76. const exitCode = await proc.exited;
  77. if (exitCode !== 0) {
  78. log(`${logPrefix} findBinary: '${cmd} ${binaryName}' failed`, {
  79. exitCode,
  80. });
  81. return null;
  82. }
  83. const stdout = await proc.stdout();
  84. const path = stdout.trim().split('\n')[0];
  85. if (!path) {
  86. log(`${logPrefix} findBinary: no path in output`);
  87. return null;
  88. }
  89. log(`${logPrefix} findBinary: found`, { path });
  90. // Verify the binary works if requested
  91. if (options.verify) {
  92. try {
  93. const verifyProc = crossSpawn([path, '-V'], {
  94. stdout: 'pipe',
  95. stderr: 'pipe',
  96. });
  97. const verifyExitCode = await verifyProc.exited;
  98. if (verifyExitCode !== 0) {
  99. log(`${logPrefix} findBinary: verification failed for ${path}`);
  100. return null;
  101. }
  102. const verifyStdout = await verifyProc.stdout();
  103. log(`${logPrefix} findBinary: verified`, {
  104. version: verifyStdout.trim(),
  105. });
  106. } catch (verifyErr) {
  107. log(`${logPrefix} findBinary: verification exception`, {
  108. error: String(verifyErr),
  109. });
  110. return null;
  111. }
  112. }
  113. return path;
  114. } catch (err) {
  115. log(`${logPrefix} findBinary: exception`, { error: String(err) });
  116. return null;
  117. }
  118. }
  119. const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
  120. export interface GracefulClosePaneOptions {
  121. /** Backend-specific Ctrl+C command args (binary prepended by caller). */
  122. ctrlC: string[];
  123. /** Backend-specific close/kill command args (binary prepended by caller). */
  124. close: string[];
  125. /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
  126. acceptExitCode1?: boolean;
  127. /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
  128. emptyPaneReturnsTrue?: boolean;
  129. }
  130. export async function gracefulClosePane(
  131. binary: string | null,
  132. paneId: string,
  133. options: GracefulClosePaneOptions,
  134. ): Promise<boolean> {
  135. if (!binary) return false;
  136. const isEmpty = !paneId || paneId === 'unknown';
  137. if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
  138. try {
  139. const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
  140. stdout: 'ignore',
  141. stderr: 'ignore',
  142. });
  143. await ctrlCProc.exited;
  144. await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
  145. const proc = crossSpawn([binary, ...options.close], {
  146. stdout: 'ignore',
  147. stderr: 'ignore',
  148. });
  149. const exitCode = await proc.exited;
  150. if (exitCode === 0) return true;
  151. if (options.acceptExitCode1 && exitCode === 1) return true;
  152. return false;
  153. } catch {
  154. return false;
  155. }
  156. }