{"repo":"NousResearch/hermes-agent","public_id":"11835","display_label":"Adding /copy command with clipboard support","summary":"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.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20159,"merged_at":"2026-05-05T10:59:00+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-05T10:59:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":11835,"pr_number":20159,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-05T10:59:00+00:00"},{"issue_number":15664,"pr_number":20159,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-05T10:59:00+00:00"},{"issue_number":18308,"pr_number":20159,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-05T10:59:00+00:00"}],"members":[{"kind":"issue","number":11835,"title":"[Feature]: Introduce /copy for hermes","url":null,"state":"closed"},{"kind":"pr","number":12362,"title":"feat: support copy command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20159","number":20159},{"kind":"pr","value":"#15963","number":15963}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":20159,"title":"fix(tui): native clipboard fallback for /copy command (salvage #15963)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"scripts/release.py","hunks":["@@ -533,6 +533,7 @@\n     \"[redacted:email]\": \"memosr\",\n     \"[redacted:email]\": \"perlowja\",\n     \"[redacted:email]\": \"web-dev0521\",\n+    \"[redacted:email]\": \"bjianhang\",\n     \"[redacted:email]\": \"tangyuanjc\",\n     \"[redacted:email]\": \"harryplusplus\",\n     \"[redacted:email]\": \"anthhub\","]},{"file":"ui-tui/src/__tests__/clipboard.test.ts","hunks":["@@ -100,11 +100,22 @@ describe('isUsableClipboardText', () => {\n })\n \n describe('writeClipboardText', () => {\n-  it('does nothing off macOS', async () => {\n-    const start = vi.fn()\n+  it('does nothing off macOS when no tools are available', async () => {\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          cb(1) // non-zero exit = failure\n+        }\n+\n+        return child\n+      }),\n+      stdin: { end: vi.fn() }\n+    }\n \n-    await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false)\n-    expect(start).not.toHaveBeenCalled()\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    // Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail\n+    await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false)\n   })\n \n   it('writes text to pbcopy on macOS', async () => {","@@ -148,4 +159,171 @@ describe('writeClipboardText', () => {\n \n     await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)\n   })\n+\n+  it('uses wl-copy on Wayland Linux', async () => {\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          cb(0)\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(\n+      writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })\n+    ).resolves.toBe(true)\n+    expect(start).toHaveBeenCalledWith(\n+      'wl-copy',\n+      ['--type', 'text/plain'],\n+      expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })\n+    )\n+    expect(stdin.end).toHaveBeenCalledWith('wayland text')\n+  })\n+\n+  it('falls back to xclip when wl-copy fails on Wayland', async () => {\n+    let callCount = 0\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          callCount++\n+          // wl-copy fails, xclip succeeds\n+          cb(callCount === 1 ? 1 : 0)\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(\n+      writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })\n+    ).resolves.toBe(true)\n+    expect(start).toHaveBeenNthCalledWith(\n+      1,\n+      'wl-copy',\n+      ['--type', 'text/plain'],\n+      expect.anything()\n+    )\n+    expect(start).toHaveBeenNthCalledWith(\n+      2,\n+      'xclip',\n+      ['-selection', 'clipboard', '-in'],\n+      expect.anything()\n+    )\n+  })\n+\n+  it('falls back to xsel when both wl-copy and xclip fail', async () => {\n+    let callCount = 0\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          callCount++\n+          cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(\n+      writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })\n+    ).resolves.toBe(true)\n+    expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything())\n+  })\n+\n+  it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => {\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          cb(0)\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true)\n+    expect(start).toHaveBeenCalledWith(\n+      'powershell.exe',\n+      expect.arrayContaining(['-NoProfile', '-NonInteractive']),\n+      expect.anything()\n+    )\n+    expect(stdin.end).toHaveBeenCalledWith('wsl text')\n+  })\n+\n+  it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          cb(0)\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(\n+      writeClipboardText('wslg text', 'linux', start as any, {\n+        WAYLAND_DISPLAY: 'wayland-0',\n+        WSL_DISTRO_NAME: 'Ubuntu'\n+      })\n+    ).resolves.toBe(true)\n+    expect(start).toHaveBeenNthCalledWith(\n+      1,\n+      'powershell.exe',\n+      expect.arrayContaining(['-NoProfile', '-NonInteractive']),\n+      expect.anything()\n+    )\n+    expect(stdin.end).toHaveBeenCalledWith('wslg text')\n+  })\n+\n+  it('uses PowerShell on Windows', async () => {\n+    const stdin = { end: vi.fn() }\n+\n+    const child = {\n+      once: vi.fn((event: string, cb: (code?: number) => void) => {\n+        if (event === 'close') {\n+          cb(0)\n+        }\n+\n+        return child\n+      }),\n+      stdin\n+    }\n+\n+    const start = vi.fn().mockReturnValue(child)\n+\n+    await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true)\n+    expect(start).toHaveBeenCalledWith(\n+      'powershell',\n+      expect.arrayContaining(['-NoProfile', '-NonInteractive']),\n+      expect.anything()\n+    )\n+  })\n })"]},{"file":"ui-tui/src/app/slash/commands/core.ts","hunks":["@@ -10,6 +10,7 @@ import type {\n   SessionTitleResponse,\n   SessionUndoResponse\n } from '../../../gatewayTypes.js'\n+import { writeClipboardText } from '../../../lib/clipboard.js'\n import { writeOsc52Clipboard } from '../../../lib/osc52.js'\n import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'\n import type { Msg, PanelSection } from '../../../types.js'","@@ -318,10 +319,27 @@ export const coreCommands: SlashCommand[] = [\n       const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]\n \n       if (!target) {\n-        return sys('nothing to copy')\n+        return sys('nothing to copy — start a conversation first')\n       }\n \n-      writeOsc52Clipboard(target.text)\n+      void writeClipboardText(target.text)\n+        .then(nativeOk => {\n+          if (ctx.stale()) {\n+            return\n+          }\n+\n+          if (nativeOk) {\n+            sys('copied to clipboard')\n+          } else {\n+            writeOsc52Clipboard(target.text)\n+            sys('sent OSC52 copy sequence (terminal support required)')\n+          }\n+        })\n+        .catch(error => {\n+          if (!ctx.stale()) {\n+            sys(`copy failed: ${String(error)}`)\n+          }\n+        })\n     }\n   },\n "]},{"file":"ui-tui/src/lib/clipboard.ts","hunks":["@@ -44,7 +44,7 @@ function readClipboardCommands(\n \n   const attempts: Array<{ args: readonly string[]; cmd: string }> = []\n \n-  if (env.WSL_INTEROP) {\n+  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {\n     attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })\n   }\n ","@@ -91,32 +91,76 @@ export async function readClipboardText(\n   return null\n }\n \n+function writeClipboardCommands(\n+  platform: NodeJS.Platform,\n+  env: NodeJS.ProcessEnv\n+): Array<{ args: readonly string[]; cmd: string }> {\n+  if (platform === 'darwin') {\n+    return [{ cmd: 'pbcopy', args: [] }]\n+  }\n+\n+  if (platform === 'win32') {\n+    return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]\n+  }\n+\n+  const attempts: Array<{ args: readonly string[]; cmd: string }> = []\n+\n+  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {\n+    attempts.push({\n+      cmd: 'powershell.exe',\n+      args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']\n+    })\n+  }\n+\n+  if (env.WAYLAND_DISPLAY) {\n+    attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })\n+  }\n+\n+  attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })\n+  attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })\n+\n+  return attempts\n+}\n+\n /**\n  * Write plain text to the system clipboard.\n  *\n- * On macOS this uses `pbcopy`. On other platforms we intentionally return\n- * false for now; non-mac copy still falls back to OSC52.\n+ * Tries native platform tools in fallback order:\n+ * - macOS: pbcopy\n+ * - Windows: PowerShell Set-Clipboard\n+ * - WSL: powershell.exe Set-Clipboard\n+ * - Linux Wayland: wl-copy --type text/plain\n+ * - Linux X11: xclip -selection clipboard -in\n+ * - Linux X11 alt: xsel --clipboard --input\n+ *\n+ * Returns true if at least one backend succeeded, false otherwise\n+ * (callers should fall back to OSC52 on false).\n  */\n export async function writeClipboardText(\n   text: string,\n   platform: NodeJS.Platform = process.platform,\n-  start: typeof spawn = spawn\n+  start: typeof spawn = spawn,\n+  env: NodeJS.ProcessEnv = process.env\n ): Promise<boolean> {\n-  if (platform !== 'darwin') {\n-    return false\n-  }\n+  const candidates = writeClipboardCommands(platform, env)\n \n-  try {\n-    const ok = await new Promise<boolean>(resolve => {\n-      const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })\n+  for (const { cmd, args } of candidates) {\n+    try {\n+      const ok = await new Promise<boolean>(resolve => {\n+        const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })\n \n-      child.once('error', () => resolve(false))\n-      child.once('close', code => resolve(code === 0))\n-      child.stdin.end(text)\n-    })\n+        child.once('error', () => resolve(false))\n+        child.once('close', code => resolve(code === 0))\n+        child.stdin?.end(text)\n+      })\n \n-    return ok\n-  } catch {\n-    return false\n+      if (ok) {\n+        return true\n+      }\n+    } catch {\n+      // Fall through to the next clipboard backend.\n+    }\n   }\n+\n+  return false\n }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}