Browse Source

Merge pull request #447 from jelasin/fix/bunx-plugin-root-install

Fix bunx installer cache warm-up
Alvin 3 months ago
parent
commit
3e825af84e

+ 4 - 2
README.md

@@ -39,8 +39,10 @@ bunx oh-my-opencode-slim@latest install
 
 The installer also registers the companion TUI plugin in OpenCode's
 `tui.json`, which adds a small sidebar showing specialist-agent status plus
-active/reusable task sessions. For manual setups, add `oh-my-opencode-slim` to
-the `plugin` array in both `opencode.json` and `tui.json`.
+active/reusable task sessions. It also warms OpenCode's plugin cache so bunx
+installs keep loading even after temporary directories are cleaned up. For
+manual setups, add `oh-my-opencode-slim` to the `plugin` array in both
+`opencode.json` and `tui.json`.
 
 ### Getting Started
 

+ 356 - 0
src/cli/cache.test.ts

@@ -0,0 +1,356 @@
+/// <reference types="bun-types" />
+
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  mock,
+  spyOn,
+  test,
+} from 'bun:test';
+import {
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+type SpawnOptions = {
+  cwd?: string;
+};
+
+const crossSpawnMock = mock((_command: string[], _options?: SpawnOptions) =>
+  createSpawnResult(),
+);
+
+mock.module('../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(exitCode = 0): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(''),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+async function importFreshConfigIo() {
+  return import(`./config-io?test=${importCounter++}`);
+}
+
+describe('warmOpenCodePluginCache', () => {
+  const originalArgv = [...process.argv];
+  const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
+
+  beforeEach(() => {
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation(
+      (_command: string[], options?: SpawnOptions) => {
+        writeCachedPluginPackage(options?.cwd);
+        return createSpawnResult();
+      },
+    );
+    delete process.env.XDG_CACHE_HOME;
+  });
+
+  afterEach(() => {
+    process.argv = [...originalArgv];
+    if (originalXdgCacheHome === undefined) {
+      delete process.env.XDG_CACHE_HOME;
+    } else {
+      process.env.XDG_CACHE_HOME = originalXdgCacheHome;
+    }
+  });
+
+  test('prewarms the OpenCode cache for bunx installs', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    const expectedCacheDir = join(
+      cacheHome,
+      'opencode',
+      'packages',
+      'oh-my-opencode-slim@latest',
+    );
+
+    expect(result?.success).toBe(true);
+    expect(result?.configPath).toBe(expectedCacheDir);
+    expect(crossSpawnMock).toHaveBeenCalledTimes(1);
+    expect(crossSpawnMock.mock.calls[0][0]).toEqual([
+      'bun',
+      'install',
+      '--ignore-scripts',
+    ]);
+    expect(crossSpawnMock.mock.calls[0][1]).toEqual(
+      expect.objectContaining({ cwd: expectedCacheDir }),
+    );
+    expect(
+      JSON.parse(readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8')),
+    ).toEqual({
+      name: 'oh-my-opencode-slim-cache',
+      private: true,
+      dependencies: {
+        'oh-my-opencode-slim': 'latest',
+      },
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('repairs a stale OpenCode cache manifest', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const expectedCacheDir = join(
+      cacheHome,
+      'opencode',
+      'packages',
+      'oh-my-opencode-slim@latest',
+    );
+    mkdirSync(expectedCacheDir, { recursive: true });
+    writeFileSync(
+      join(expectedCacheDir, 'package.json'),
+      JSON.stringify({
+        name: 'stale-cache',
+        scripts: { postinstall: 'should-not-run' },
+        dependencies: { other: '1.0.0' },
+      }),
+    );
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result?.success).toBe(true);
+    expect(
+      JSON.parse(readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8')),
+    ).toEqual({
+      name: 'oh-my-opencode-slim-cache',
+      private: true,
+      dependencies: {
+        'oh-my-opencode-slim': 'latest',
+      },
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('fails when bun install does not create the cached plugin package', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+    crossSpawnMock.mockImplementation(() => createSpawnResult());
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result).toEqual({
+      success: false,
+      configPath: join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+      ),
+      error: `Cached plugin package not found at ${join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+        'node_modules',
+        'oh-my-opencode-slim',
+        'package.json',
+      )}`,
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('returns a failed result when bun install fails', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+    crossSpawnMock.mockImplementation(() => ({
+      ...createSpawnResult(1),
+      stderr: () => Promise.resolve('registry unavailable'),
+    }));
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result).toEqual({
+      success: false,
+      configPath: join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+      ),
+      error: 'registry unavailable',
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('returns a failed result when cache package.json cannot be written', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const packageJsonSuffix = join(
+      'oh-my-opencode-slim@latest',
+      'package.json',
+    );
+    const fs = await import('node:fs');
+    const originalWriteFileSync = fs.writeFileSync;
+    const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+      (path, data, options) => {
+        if (String(path).endsWith(packageJsonSuffix)) {
+          throw new Error('disk full');
+        }
+        return originalWriteFileSync(path, data, options);
+      },
+    );
+    try {
+      const { warmOpenCodePluginCache } = await importFreshConfigIo();
+      const result = await warmOpenCodePluginCache();
+
+      expect(result).toEqual({
+        success: false,
+        configPath: join(
+          cacheHome,
+          'opencode',
+          'packages',
+          'oh-my-opencode-slim@latest',
+        ),
+        error: 'Failed to write cache package.json: Error: disk full',
+      });
+      expect(crossSpawnMock).not.toHaveBeenCalled();
+    } finally {
+      writeSpy.mockRestore();
+      rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+
+  test('skips cache warm-up for local repo installs', async () => {
+    const tmpDir = mkdirTemp();
+    const packageRoot = join(tmpDir, 'repo');
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result).toBeNull();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+});
+
+function mkdirTemp(): string {
+  return mkdtempSync(join(tmpdir(), 'opencode-cache-test-'));
+}
+
+function writeCachedPluginPackage(cacheDir?: string): void {
+  if (!cacheDir) return;
+
+  const pluginRoot = join(cacheDir, 'node_modules', 'oh-my-opencode-slim');
+  mkdirSync(pluginRoot, { recursive: true });
+  writeFileSync(
+    join(pluginRoot, 'package.json'),
+    JSON.stringify({ name: 'oh-my-opencode-slim' }),
+  );
+}

+ 139 - 5
src/cli/config-io.ts

@@ -1,12 +1,15 @@
 import {
   copyFileSync,
   existsSync,
+  mkdirSync,
   readFileSync,
   renameSync,
   statSync,
   writeFileSync,
 } from 'node:fs';
+import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
+import { crossSpawn } from '../utils/compat';
 import {
   ensureConfigDir,
   ensureOpenCodeConfigDir,
@@ -79,11 +82,6 @@ function findPackageRoot(startPath: string): string | null {
   }
 }
 
-function isPackageManagerInstall(path: string): boolean {
-  const normalizedPath = normalizePathForMatch(path);
-  return normalizedPath.includes(`/node_modules/${PACKAGE_NAME}`);
-}
-
 function isLocalPackageRootEntry(entry: string): boolean {
   if (!entry || entry.startsWith('file://')) {
     return false;
@@ -104,6 +102,11 @@ function isLocalPackageRootEntry(entry: string): boolean {
   }
 }
 
+function isPackageManagerInstall(path: string): boolean {
+  const normalizedPath = normalizePathForMatch(path);
+  return normalizedPath.includes(`/node_modules/${PACKAGE_NAME}`);
+}
+
 function isPluginEntry(entry: string): boolean {
   return (
     entry === PACKAGE_NAME ||
@@ -138,6 +141,137 @@ function getPluginEntry(): string {
   }
 }
 
+function getOpenCodePluginCacheDir(): string {
+  const cacheDir =
+    process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
+  return join(cacheDir, 'opencode', 'packages', `${PACKAGE_NAME}@latest`);
+}
+
+function writeOpenCodePluginCacheManifest(
+  cacheDir: string,
+): ConfigMergeResult | null {
+  try {
+    writeFileSync(
+      join(cacheDir, 'package.json'),
+      JSON.stringify(
+        {
+          name: `${PACKAGE_NAME}-cache`,
+          private: true,
+          dependencies: {
+            [PACKAGE_NAME]: 'latest',
+          },
+        },
+        null,
+        2,
+      ),
+    );
+    return null;
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to write cache package.json: ${err}`,
+    };
+  }
+}
+
+function verifyOpenCodePluginCache(cacheDir: string): ConfigMergeResult | null {
+  const pluginPackageJsonPath = join(
+    cacheDir,
+    'node_modules',
+    PACKAGE_NAME,
+    'package.json',
+  );
+
+  if (!existsSync(pluginPackageJsonPath)) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Cached plugin package not found at ${pluginPackageJsonPath}`,
+    };
+  }
+
+  try {
+    const packageJson = JSON.parse(
+      readFileSync(pluginPackageJsonPath, 'utf-8'),
+    ) as {
+      name?: string;
+    };
+
+    if (packageJson.name !== PACKAGE_NAME) {
+      return {
+        success: false,
+        configPath: cacheDir,
+        error: `Cached plugin package has unexpected name: ${packageJson.name}`,
+      };
+    }
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to verify cached plugin package: ${err}`,
+    };
+  }
+
+  return null;
+}
+
+export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | null> {
+  const cliEntryPath = process.argv[1];
+  if (!cliEntryPath) {
+    return null;
+  }
+
+  const packageRoot = findPackageRoot(cliEntryPath);
+  if (!packageRoot || !isPackageManagerInstall(packageRoot)) {
+    return null;
+  }
+
+  const cacheDir = getOpenCodePluginCacheDir();
+
+  try {
+    mkdirSync(cacheDir, { recursive: true });
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to create OpenCode cache directory: ${err}`,
+    };
+  }
+
+  const manifestError = writeOpenCodePluginCacheManifest(cacheDir);
+  if (manifestError) return manifestError;
+
+  try {
+    const proc = crossSpawn(['bun', 'install', '--ignore-scripts'], {
+      cwd: cacheDir,
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    await proc.exited;
+
+    if (proc.exitCode !== 0) {
+      const stderr = (await proc.stderr()).trim();
+      return {
+        success: false,
+        configPath: cacheDir,
+        error: stderr || `bun install exited with code ${proc.exitCode}`,
+      };
+    }
+
+    const verificationError = verifyOpenCodePluginCache(cacheDir);
+    if (verificationError) return verificationError;
+
+    return { success: true, configPath: cacheDir };
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to warm OpenCode cache: ${err}`,
+    };
+  }
+}
+
 /**
  * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
  */

+ 1 - 1
src/cli/doctor.test.ts

@@ -484,7 +484,7 @@ describe('doctor CLI wrapper', () => {
       expect(exitCode).toBe(0);
       const parsed = JSON.parse(output);
       expect(parsed.ok).toBe(true);
-      expect(parsed.project).toBe(projectDir);
+      expect(fs.realpathSync(parsed.project)).toBe(fs.realpathSync(projectDir));
       expect(parsed.configs).toHaveLength(2);
       expect(parsed.configs[0].scope).toBe('user');
       expect(parsed.configs[1].scope).toBe('project');

+ 16 - 0
src/cli/install.ts

@@ -10,6 +10,7 @@ import {
   getOpenCodePath,
   getOpenCodeVersion,
   isOpenCodeInstalled,
+  warmOpenCodePluginCache,
   writeLiteConfig,
 } from './config-manager';
 import { CUSTOM_SKILLS, installCustomSkill } from './custom-skills';
@@ -153,6 +154,7 @@ async function runInstall(config: InstallConfig): Promise<number> {
   let totalSteps = 6;
   if (config.installSkills) totalSteps += 1;
   if (config.installCustomSkills) totalSteps += 1;
+  totalSteps += 1;
 
   let step = 1;
 
@@ -183,6 +185,20 @@ async function runInstall(config: InstallConfig): Promise<number> {
     }
   }
 
+  printStep(step++, totalSteps, 'Warming OpenCode plugin cache...');
+  if (config.dryRun) {
+    printInfo('Dry run mode - skipping cache warm-up');
+  } else {
+    const cacheResult = await warmOpenCodePluginCache();
+    if (cacheResult === null) {
+      printInfo('Local development install - cache warm-up not required');
+    } else if (!cacheResult.success) {
+      printInfo(`Skipped cache warm-up: ${cacheResult.error}`);
+    } else {
+      handleStepResult(cacheResult, 'OpenCode cache warmed');
+    }
+  }
+
   printStep(step++, totalSteps, 'Disabling OpenCode default agents...');
   if (config.dryRun) {
     printInfo('Dry run mode - skipping agent disabling');

+ 1 - 1
src/hooks/foreground-fallback/index.ts

@@ -15,8 +15,8 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
-import { abortSessionWithTimeout } from '../../utils/session';
 import { log } from '../../utils/logger';
+import { abortSessionWithTimeout } from '../../utils/session';
 
 type OpencodeClient = PluginInput['client'];
 

+ 22 - 5
src/multiplexer/tmux/index.test.ts

@@ -60,7 +60,8 @@ describe('TmuxMultiplexer', () => {
     logMock.mockClear();
     crossSpawnMock.mockReset();
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[0] === 'which')
+        return createSpawnResult(0, '/usr/bin/tmux\n');
       if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
       if (command[1] === 'split-window') {
         return createSpawnResult(0, '%2\n');
@@ -78,8 +79,18 @@ describe('TmuxMultiplexer', () => {
     const { TmuxMultiplexer } = await importFreshTmux();
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
-    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
-    await tmux.spawnPane('session-2', 'Second worker', 'http://localhost:4096', '/repo');
+    await tmux.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    await tmux.spawnPane(
+      'session-2',
+      'Second worker',
+      'http://localhost:4096',
+      '/repo',
+    );
 
     expect(
       commands().filter((command) => command.includes('select-layout')),
@@ -105,7 +116,8 @@ describe('TmuxMultiplexer', () => {
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[0] === 'which')
+        return createSpawnResult(0, '/usr/bin/tmux\n');
       if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
       if (command.includes('select-layout')) {
         return createSpawnResult(1, '', 'layout failed');
@@ -134,7 +146,12 @@ describe('TmuxMultiplexer', () => {
     const { TmuxMultiplexer } = await importFreshTmux();
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
-    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
+    await tmux.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
     await tmux.applyLayout('tiled', 60);
     await wait(300);
 

+ 17 - 21
src/multiplexer/tmux/index.ts

@@ -209,10 +209,11 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // Apply the layout
-      const layoutResult = await this.runTmux(
-        tmux,
-        ['select-layout', ...this.targetArgs(), layout],
-      );
+      const layoutResult = await this.runTmux(tmux, [
+        'select-layout',
+        ...this.targetArgs(),
+        layout,
+      ]);
       if (layoutResult !== 0) return;
 
       // For main-* layouts, set the main pane size
@@ -220,22 +221,20 @@ export class TmuxMultiplexer implements Multiplexer {
         const sizeOption =
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
 
-        const sizeResult = await this.runTmux(
-          tmux,
-          [
-            'set-window-option',
-            ...this.targetArgs(),
-            sizeOption,
-            `${mainPaneSize}%`,
-          ],
-        );
+        const sizeResult = await this.runTmux(tmux, [
+          'set-window-option',
+          ...this.targetArgs(),
+          sizeOption,
+          `${mainPaneSize}%`,
+        ]);
         if (sizeResult !== 0) return;
 
         // Reapply layout to use the new size
-        const reapplyResult = await this.runTmux(
-          tmux,
-          ['select-layout', ...this.targetArgs(), layout],
-        );
+        const reapplyResult = await this.runTmux(tmux, [
+          'select-layout',
+          ...this.targetArgs(),
+          layout,
+        ]);
         if (reapplyResult !== 0) return;
       }
 
@@ -245,10 +244,7 @@ export class TmuxMultiplexer implements Multiplexer {
     }
   }
 
-  private async runTmux(
-    tmux: string,
-    args: string[],
-  ): Promise<number> {
+  private async runTmux(tmux: string, args: string[]): Promise<number> {
     const proc = crossSpawn([tmux, ...args], {
       stdout: 'pipe',
       stderr: 'pipe',

+ 2 - 10
src/utils/session.test.ts

@@ -34,11 +34,7 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(
-        client,
-        { path: { id: 's1' }, body: { parts: [] } },
-        5,
-      ),
+      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
 
     expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
@@ -57,11 +53,7 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(
-        client,
-        { path: { id: 's1' }, body: { parts: [] } },
-        5,
-      ),
+      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
   });
 

+ 5 - 7
src/utils/session.ts

@@ -11,14 +11,14 @@ export const SESSION_ABORT_TIMEOUT_MS = 1_000;
 export class OperationTimeoutError extends Error {
   constructor(message: string) {
     super(message);
-    this.name = "OperationTimeoutError";
+    this.name = 'OperationTimeoutError';
   }
 }
 
 export async function withTimeout<T>(
   operation: Promise<T>,
   timeoutMs: number,
-  message: string
+  message: string,
 ): Promise<T> {
   if (timeoutMs <= 0) return operation;
 
@@ -40,12 +40,12 @@ export async function withTimeout<T>(
 export async function abortSessionWithTimeout(
   client: OpencodeClient,
   sessionId: string,
-  timeoutMs = SESSION_ABORT_TIMEOUT_MS
+  timeoutMs = SESSION_ABORT_TIMEOUT_MS,
 ): Promise<void> {
   await withTimeout(
     client.session.abort({ path: { id: sessionId } }),
     timeoutMs,
-    `Session abort timed out after ${timeoutMs}ms`
+    `Session abort timed out after ${timeoutMs}ms`,
   );
 }
 
@@ -116,9 +116,7 @@ export async function promptWithTimeout(
       new Promise<never>((_, reject) => {
         timer = setTimeout(() => {
           reject(
-            new OperationTimeoutError(
-              `Prompt timed out after ${timeoutMs}ms`,
-            ),
+            new OperationTimeoutError(`Prompt timed out after ${timeoutMs}ms`),
           );
         }, timeoutMs);
       }),