NousResearch/hermes-agent

Adding /copy command with clipboard support

Cluster 11835 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #20159 on 5 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #20159. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #20159 fix(tui): native clipboard fallback for /copy command (salvage #15963)

    merged to main

    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

    Show the diff
    • scripts/release.py
      @@ -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
      @@ -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 () => {
      @@ -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
      @@ -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'
      @@ -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
      @@ -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 })
         }
       
      @@ -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
       }

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #12362 feat: support copy command · superseded by another PR → #20159, #15963
Closed issues · 1
  • Closed#11835[Feature]: Introduce /copy for hermes
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#20159
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.