# NousResearch/hermes-agent · Adding /copy command with clipboard support

## Open now

- Nothing open on GitHub.

These members introduce a /copy command feature and address clipboard handling, including a native clipboard fallback mechanism. The work spans feature introduction and implementation details around clipboard integration.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #20159. Verify it addresses your case.
- **Recommended:** read PR #20159 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #11835: \[Feature\]: Introduce /copy for hermes

## Prior & competing PRs

- **#20159: fix(tui): native clipboard fallback for /copy command (salvage #15963)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `ui-tui/src/__tests__/clipboard.test.ts`, `ui-tui/src/app/slash/commands/core.ts`, `ui-tui/src/lib/clipboard.ts`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -533,6 +533,7 @@
         "[redacted:email]": "memosr",
         "[redacted:email]": "perlowja",
         "[redacted:email]": "web-dev0521",
    +    "[redacted:email]": "bjianhang",
         "[redacted:email]": "tangyuanjc",
         "[redacted:email]": "harryplusplus",
         "[redacted:email]": "anthhub",
    ```
  - `ui-tui/src/__tests__/clipboard.test.ts`

    ```diff
    @@ -100,11 +100,22 @@ describe('isUsableClipboardText', () => {
     })
     
     describe('writeClipboardText', () => {
    -  it('does nothing off macOS', async () => {
    -    const start = vi.fn()
    +  it('does nothing off macOS when no tools are available', async () => {
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          cb(1) // non-zero exit = failure
    +        }
    +
    +        return child
    +      }),
    +      stdin: { end: vi.fn() }
    +    }
     
    -    await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false)
    -    expect(start).not.toHaveBeenCalled()
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    // Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail
    +    await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false)
       })
     
       it('writes text to pbcopy on macOS', async () => {
    ```

    ```diff
    @@ -148,4 +159,171 @@ describe('writeClipboardText', () => {
     
         await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)
       })
    +
    +  it('uses wl-copy on Wayland Linux', async () => {
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          cb(0)
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(
    +      writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
    +    ).resolves.toBe(true)
    +    expect(start).toHaveBeenCalledWith(
    +      'wl-copy',
    +      ['--type', 'text/plain'],
    +      expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
    +    )
    +    expect(stdin.end).toHaveBeenCalledWith('wayland text')
    +  })
    +
    +  it('falls back to xclip when wl-copy fails on Wayland', async () => {
    +    let callCount = 0
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          callCount++
    +          // wl-copy fails, xclip succeeds
    +          cb(callCount === 1 ? 1 : 0)
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(
    +      writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
    +    ).resolves.toBe(true)
    +    expect(start).toHaveBeenNthCalledWith(
    +      1,
    +      'wl-copy',
    +      ['--type', 'text/plain'],
    +      expect.anything()
    +    )
    +    expect(start).toHaveBeenNthCalledWith(
    +      2,
    +      'xclip',
    +      ['-selection', 'clipboard', '-in'],
    +      expect.anything()
    +    )
    +  })
    +
    +  it('falls back to xsel when both wl-copy and xclip fail', async () => {
    +    let callCount = 0
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          callCount++
    +          cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(
    +      writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
    +    ).resolves.toBe(true)
    +    expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything())
    +  })
    +
    +  it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => {
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          cb(0)
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true)
    +    expect(start).toHaveBeenCalledWith(
    +      'powershell.exe',
    +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
    +      expect.anything()
    +    )
    +    expect(stdin.end).toHaveBeenCalledWith('wsl text')
    +  })
    +
    +  it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          cb(0)
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(
    +      writeClipboardText('wslg text', 'linux', start as any, {
    +        WAYLAND_DISPLAY: 'wayland-0',
    +        WSL_DISTRO_NAME: 'Ubuntu'
    +      })
    +    ).resolves.toBe(true)
    +    expect(start).toHaveBeenNthCalledWith(
    +      1,
    +      'powershell.exe',
    +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
    +      expect.anything()
    +    )
    +    expect(stdin.end).toHaveBeenCalledWith('wslg text')
    +  })
    +
    +  it('uses PowerShell on Windows', async () => {
    +    const stdin = { end: vi.fn() }
    +
    +    const child = {
    +      once: vi.fn((event: string, cb: (code?: number) => void) => {
    +        if (event === 'close') {
    +          cb(0)
    +        }
    +
    +        return child
    +      }),
    +      stdin
    +    }
    +
    +    const start = vi.fn().mockReturnValue(child)
    +
    +    await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true)
    +    expect(start).toHaveBeenCalledWith(
    +      'powershell',
    +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
    +      expect.anything()
    +    )
    +  })
     })
    ```
  - `ui-tui/src/app/slash/commands/core.ts`

    ```diff
    @@ -10,6 +10,7 @@ import type {
       SessionTitleResponse,
       SessionUndoResponse
     } from '../../../gatewayTypes.js'
    +import { writeClipboardText } from '../../../lib/clipboard.js'
     import { writeOsc52Clipboard } from '../../../lib/osc52.js'
     import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
     import type { Msg, PanelSection } from '../../../types.js'
    ```

    ```diff
    @@ -318,10 +319,27 @@ export const coreCommands: SlashCommand[] = [
           const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
     
           if (!target) {
    -        return sys('nothing to copy')
    +        return sys('nothing to copy — start a conversation first')
           }
     
    -      writeOsc52Clipboard(target.text)
    +      void writeClipboardText(target.text)
    +        .then(nativeOk => {
    +          if (ctx.stale()) {
    +            return
    +          }
    +
    +          if (nativeOk) {
    +            sys('copied to clipboard')
    +          } else {
    +            writeOsc52Clipboard(target.text)
    +            sys('sent OSC52 copy sequence (terminal support required)')
    +          }
    +        })
    +        .catch(error => {
    +          if (!ctx.stale()) {
    +            sys(`copy failed: ${String(error)}`)
    +          }
    +        })
         }
       },
     
    ```
  - `ui-tui/src/lib/clipboard.ts`

    ```diff
    @@ -44,7 +44,7 @@ function readClipboardCommands(
     
       const attempts: Array<{ args: readonly string[]; cmd: string }> = []
     
    -  if (env.WSL_INTEROP) {
    +  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
         attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
       }
     
    ```

    ```diff
    @@ -91,32 +91,76 @@ export async function readClipboardText(
       return null
     }
     
    +function writeClipboardCommands(
    +  platform: NodeJS.Platform,
    +  env: NodeJS.ProcessEnv
    +): Array<{ args: readonly string[]; cmd: string }> {
    +  if (platform === 'darwin') {
    +    return [{ cmd: 'pbcopy', args: [] }]
    +  }
    +
    +  if (platform === 'win32') {
    +    return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]
    +  }
    +
    +  const attempts: Array<{ args: readonly string[]; cmd: string }> = []
    +
    +  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
    +    attempts.push({
    +      cmd: 'powershell.exe',
    +      args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']
    +    })
    +  }
    +
    +  if (env.WAYLAND_DISPLAY) {
    +    attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })
    +  }
    +
    +  attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })
    +  attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })
    +
    +  return attempts
    +}
    +
     /**
      * Write plain text to the system clipboard.
      *
    - * On macOS this uses `pbcopy`. On other platforms we intentionally return
    - * false for now; non-mac copy still falls back to OSC52.
    + * Tries native platform tools in fallback order:
    + * - macOS: pbcopy
    + * - Windows: PowerShell Set-Clipboard
    + * - WSL: powershell.exe Set-Clipboard
    + * - Linux Wayland: wl-copy --type text/plain
    + * - Linux X11: xclip -selection clipboard -in
    + * - Linux X11 alt: xsel --clipboard --input
    + *
    + * Returns true if at least one backend succeeded, false otherwise
    + * (callers should fall back to OSC52 on false).
      */
     export async function writeClipboardText(
       text: string,
       platform: NodeJS.Platform = process.platform,
    -  start: typeof spawn = spawn
    +  start: typeof spawn = spawn,
    +  env: NodeJS.ProcessEnv = process.env
     ): Promise<boolean> {
    -  if (platform !== 'darwin') {
    -    return false
    -  }
    +  const candidates = writeClipboardCommands(platform, env)
     
    -  try {
    -    const ok = await new Promise<boolean>(resolve => {
    -      const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
    +  for (const { cmd, args } of candidates) {
    +    try {
    +      const ok = await new Promise<boolean>(resolve => {
    +        const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
     
    -      child.once('error', () => resolve(false))
    -      child.once('close', code => resolve(code === 0))
    -      child.stdin.end(text)
    -    })
    +        child.once('error', () => resolve(false))
    +        child.once('close', code => resolve(code === 0))
    +        child.stdin?.end(text)
    +      })
     
    -    return ok
    -  } catch {
    -    return false
    +      if (ok) {
    +        return true
    +      }
    +    } catch {
    +      // Fall through to the next clipboard backend.
    +    }
       }
    +
    +  return false
     }
    ```
- **#12362: feat: support copy command**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#20159`, `#15963`

## Fix status

- Merged to main in PR #20159 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-05`.
- Subsystem: `hermes_cli`
