SLOPSHOPPER

cc-askpass

Lets Claude run commands that ask for a password (sudo, ssh, git): a pane inside Claude Code asks you, and the password never reaches the model

newpanebandguardcommandtoast
★ 13v0.2.0MITupdated 2026-10-12GoneTone/cc-askpass
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-askpass
│ ┃ askpass ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No pending password request. │ cc-askpass │ │ ⏺ Read(src/auth.ts) │ cc-askpass: the askpass listener did not │ │ ⎿ Read 6 lines │ start │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line ╭─────────────────────────────╮ │ ⏺ Bash(bun test) │ cc-askpass │ │ ⎿ 3 pass, 1 fail │ No pending password request │ │ ╰─────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /askpass │ ⎿ cc-askpass: No pending password request. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · askpass
No pending password request.
README

cc-askpass

English | 繁體中文 | 简体中文

A Claude Code mod that lets Claude run commands that need sudo, instead of copying them into your own terminal.

When a command Claude runs asks for a password (sudo apt install, an install script that calls sudo, git push over HTTPS, an ssh key passphrase), cc-askpass opens a password pane inside Claude Code where you can type it. The password goes straight to the program that asked. It never reaches the model, the transcript or any log.

https://github.com/user-attachments/assets/bbb11cc1-cbfb-4b46-ba88-23fbf1ab6254

Without it, Claude's Bash tool has no terminal, so sudo fails at once:

sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required

So you end up running the command in your own terminal. Pasting the password into the chat or adding NOPASSWD to sudoers also gets around it, but both are riskier.

  • Nothing to configure. Install it, and sudo, ssh and git ask for passwords in the pane.
  • The password stays out of the model. It never appears in the model's context, in logs, on a command line or in a file.
  • You see what you approve. The pane shows the command that will run as root, the program that asked, and the command Claude ran.

Install

Needs Claude Code 2.1.286 or later, on Linux, WSL, macOS or Windows. Run these in Claude Code:

/plugin marketplace add GoneTone/cc-askpass
/plugin install cc-askpass@cc-askpass

/plugin install asks where to install it and shows its options; the defaults are fine. Then restart Claude Code or run /reload-plugins.

Usage

When a command Claude runs asks for a password, the pane opens. Its $ line shows the command that asked; for sudo, that is the command that will run as root (for example $ sudo apt update). Asked by: names the program that asked. If the prompt came from something else Claude ran (such as ./install.sh), Claude ran: shows that command. Answer only if these are what you expect: type the password and press Enter.

  • Show full command appears when a long Claude ran: or $ line is cut short, and shows both lines in full; Show less cuts them again.
  • Show/Hide shows or hides what you typed.
  • Cancel or Esc sends no answer, so sudo stops.
  • If the password is wrong, sudo asks again.
  • If you were typing in the prompt (or a dialog was open) when the pane appeared, your keys still go to the prompt. The pane then shows a red note: move the keyboard to the pane before typing, so your password does not end up in the prompt and get sent to Claude. To do that, click the pane or press ctrl+x tab (outside fullscreen mode clicking does not work, so use the keys).
  • In a terminal narrower than 144 columns the pane may not open on its own. A line above the prompt then says, for example, sudo is waiting for a password, with an Open password prompt button. Click the button or type /askpass; both work while Claude is busy. Outside fullscreen mode, press ctrl+x tab then Enter instead of clicking.
  • What you type is hidden, except for git's Username for '...' and ssh's (yes/no) host-key question.

Password prompts on hosts reached with ssh use the same pane: ssh host sudo apt update, sudo inside a remote script (ssh host ./deploy.sh), ssh from that host on to another one, and rsync, ansible or scripts that call ssh. A remote sudo run with -S (password from standard input) or -n (never ask), as ansible's become does, does not use the pane.

The pane names the host (sudo on server needs your password) and marks the $ line (reported by server), since that command comes from the remote host and cannot be checked locally. It always shows Claude ran:, with a warning to check it. Type that host's password, never a local one.

Some hosts cannot send prompts back: network devices, accounts with a forced command, Windows sshd, and hosts without /bin/sh. cc-askpass detects them, and the command still runs, but prompts on those hosts do not reach the pane. git over ssh (git push to git@github.com) is left alone entirely.

Every ssh command cc-askpass handles opens a new connection of its own. It does not reuse a shared connection set up with ControlMaster/ControlPersist in your ~/.ssh/config, nor a caller's own ControlPath or -S (so tools such as ansible open an extra connection for each command). If logging in to a host takes a password or a two-factor code, you are therefore asked for it on every command. To avoid that, add the host to excludeHosts: ssh to it then runs with your original arguments, so your own shared connection works again, but sudo prompts on that host no longer reach the pane. Each handled command also starts a few extra login shells on the host.

Two options control this. Set them with /plugin configure cc-askpass@cc-askpass in Claude Code, or in your settings:

  • remoteHosts (default on): show password prompts from remote hosts in the pane. Turn it off with "pluginConfigs": { "cc-askpass@cc-askpass": { "options": { "remoteHosts": false } } }.
  • excludeHosts: glob patterns matched against the host name. cc-askpass leaves ssh to a matching host alone.

Every command Claude runs is a new process, so sudo asks every time. cc-askpass does not cache passwords, since any program running as you could read the cache. Instead, you can have sudo remember that you already entered it: run sudo visudo -f /etc/sudoers.d/cc-askpass and add:

Defaults:YOUR_USER timestamp_type=global, timestamp_timeout=5

sudo then shares one timestamp across all your terminals and Claude Code sessions, and asks again only after 5 minutes.

This weakens what cc-askpass protects. Until the timestamp expires:

  • Claude can run sudo without the pane appearing, so you no longer see or approve each command that runs as root.
  • Any other program running as you, in any terminal, can also run sudo without a password.

Turn it on only if you accept that. Keep the timeout short, and run sudo -k when you are done to end it at once. Without timestamp_timeout, sudo uses its default: 5 minutes, or 15 on some distributions (Ubuntu, for example).

  • Git for Windows is optional: without it Claude Code uses the PowerShell tool only. Both tools are covered: Git for Windows' ssh in the Bash tool, and Windows OpenSSH in the PowerShell tool.
  • ssh key passphrases, ssh password logins, git over HTTPS or ssh, and sudo on remote hosts use the pane. Windows' own sudo.exe and UAC prompts are not handled; they show their own Windows dialog.
  • On the first session start, and after a plugin update, cc-askpass builds its helper program from the C# source in win/ with the .NET Framework built into Windows 10 and 11. Nothing binary is downloaded or shipped. The build goes to win/out/ and takes a moment.
  • On machines that block Add-Type or unsigned programs (AppLocker, WDAC, an enforced execution policy), cc-askpass stays off, and a toast says why.
  • Remote hosts: Windows' ssh cannot share connections, so ssh host … opens two connections, one for the prompts and one for the command. A password login, or a key with a passphrase and no agent, is therefore asked twice per command; agent logins and keys without a passphrase notice nothing.
  • The password is never written to the model's context, logs, toasts, the status line, command-line arguments, or files.
  • Any program running as you can open the pane, including scripts Claude runs, and gets what you type if you answer it. cc-askpass keeps the password away from the model in normal use, but it is not a sandbox and cannot stop hostile code running as you.
  • Asked by: comes from the operating system where possible. On Linux and macOS it relies on the process ID that cc-askpass's helper reports, so a program that sends its request just as a real sudo prompt appears could be shown as that sudo. On Windows the kernel reports the process ID.
  • A request that did not come through cc-askpass's helper is marked as unverified.
  • When you answer an ssh or git prompt, the password goes to the server named in that command.
  • su and doas cannot use askpass. Use sudo.
  • polkit (pkexec, run0, systemctl as a normal user) fails instead of asking: systemctl with "Interactive authentication required", pkexec with "Error creating textual authentication agent".
  • Yes/no questions that a program asks by itself (such as apt's Do you want to continue? [Y/n]) are not handled. Use flags such as -y or --no-interaction.
  • In hidden input, deleting a character in the middle removes the last character instead. If you edit in the middle, press Show to check, or clear the field and type again.
  • On hosts without a display, the placeholder DISPLAY=:0 that cc-askpass sets may make a graphical program try to reach an X server that is not there, and fail.
  • The mobile app (and Remote Control from a phone) cannot show an input field; answer from the terminal or the desktop app.
  • In non-interactive runs (claude -p, the SDK) cc-askpass does nothing, since nobody is there to answer; sudo fails as it would without it.
  • With the Bash sandbox on, sudo cannot run inside it and fails at once (on Linux with The "no new privileges" flag is set, which prevents sudo from running as root, on macOS with operation not permitted: sudo). If you let Claude run the command outside the sandbox, the pane asks as usual.
  • ssh confirmation prompts (SSH_ASKPASS_PROMPT=confirm) appear as an ordinary text prompt.
  • Windows: programs that show their own password window (Git Credential Manager, Pageant) keep doing so. If you close Git Credential Manager's window, git asks in the pane instead.
  • On a remote host, sudo called by its full path (such as /usr/bin/sudo) does not use the pane: it fails for lack of a terminal, or reads the terminal under ssh -t. Call it as plain sudo.
  • If a shell startup file on the remote host (such as .bashrc or .zshenv) prints text when a command runs, a welcome message for example, cc-askpass cannot confirm that the host supports it. The command still runs, but sudo there does not reach the pane. To fix it, print that text only in interactive shells, or send it to stderr.
  • If an ssh option before the host name, or the host name itself, contains a space, such as -o "User alice" or -i "/keys/my key", cc-askpass leaves that ssh command alone, so sudo on the remote host does not reach the pane. For -o, drop the space: -o User=alice.

cc-askpass has two parts: a mod inside Claude Code that draws the pane, and a small helper program (bin/askpass) that sudo and ssh run to ask for a password.

Claude's Bash command → sudo → bin/askpass → request FIFO → cc-askpass mod → pane
                                     ↑                                          │
                                     └──────────── reply FIFO ←─────────────────┘

When a session starts, the mod starts a listener (bin/askpass --serve) and sets SUDO_ASKPASS and SSH_ASKPASS for the commands Claude runs (plus DISPLAY=:0 if it was unset, since sudo only uses askpass when DISPLAY is set). sudo then runs bin/askpass, which hands the prompt to the mod through a FIFO in a private directory (mode 0700). The mod uses ps to find which program started the helper, shows the pane, and writes your answer into that helper's own FIFO. It also tells Claude in its system prompt that sudo can ask for a password, so Claude runs sudo instead of sudo -n.

For ssh, the mod puts a small ssh wrapper first on the PATH of the commands Claude runs. The wrapper checks that the host supports this and sets up a relay, so a prompt raised there (remote sudo, or ssh on to another host) comes back to the same pane. On hosts that fail the check, the command still runs, without remote prompts.

On Windows the helper is one .exe that talks to the mod over a named pipe only your user can open.

Uninstall

Run these in Claude Code:

/plugin uninstall cc-askpass@cc-askpass
/plugin marketplace remove cc-askpass

The second line removes the marketplace that the install added. If you added the sudoers file, remove it with sudo rm /etc/sudoers.d/cc-askpass.

Development

Run your checkout

claude --plugin-dir .

This starts Claude Code with the plugin loaded from this directory. After you change a file, run /reload-plugins or restart Claude Code. On Windows the helper is rebuilt by itself when win/cc-askpass.cs changes, since each build goes to win/out/<hash of the source>.

Tests

claude plugin validate .
claude plugin test .

validate checks the plugin's files. test runs the mod's tests (hooks/*.test.ts) inside Claude Code's test kit; it needs no login and works on every platform.

sh tests/askpass.sh   # Linux, WSL, macOS
sh tests/ssh.sh       # Linux, WSL, macOS

tests/askpass.sh tests the helper. tests/ssh.sh tests the ssh wrapper; its end-to-end part starts its own sshd on 127.0.0.1 as your user, so it needs sshd installed (openssh-server on Debian and Ubuntu). Without it those tests are skipped with a warning. It also checks that every shell it finds (sh, bash, zsh, fish) reads the remote setup command the same way, and uses python3 for a few socket checks when available.

powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/win.ps1

Run it from the repository root on Windows. It compiles the helper and tests it.

Type-check

npx -p typescript tsc -p .

tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes the first time it loads the mod, so run claude --plugin-dir . once before this. That directory is git-ignored.

CI

GitHub Actions runs validate and test on Ubuntu, macOS and Windows, then tests/askpass.sh and tests/ssh.sh on Ubuntu and macOS, and tests/win.ps1 on Windows. A failure on one platform does not stop the others.

Conventions

  • Files use LF line endings (see .gitattributes).
  • Keep tests/win.ps1 ASCII: Windows PowerShell reads a script without a BOM in the ANSI code page.

License

MIT

Source 2 files
hooks/register.tsx 978 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { askedByLine, type AskSource, type Requester, type WinRequest } from './core'
4import {
5  AnsweredPids,
6  applyMaskedInput,
7  bashShellArgs,
8  bashSshFunction,
9  isBashSshFunction,
10  claudePidOf,
11  clipLines,
12  matchBashCommand,
13  parsePsTable,
14  isOurHelper,
15  parseExePath,
16  parseHelperPs,
17  parseParentPs,
18  parseReady,
19  parseRequest,
20  parseWinLine,
21  parseWinReady,
22  promptLabel,
23  relayHost,
24  remoteLabel,
25  stripControl,
26  withShim,
27  withoutShim,
28  wrappedLines,
29  shouldMask,
30  splitLines,
31  fnv1a64,
32  winClaudeCommand,
33  winMsysShell,
34  winRequester,
35} from './core'
36
37const READY_TIMEOUT_MS = 5000
38const REPLY_TIMEOUT_MS = 5000
39const BUILD_TIMEOUT_MS = 60_000
40const ALIVE_POLL_MS = 2000
41// Models add `-n` to sudo to avoid prompts they expect to hang; while the listener runs,
42// the system prompt says sudo can ask instead.
43const SUDO_NOTE =
44  'sudo can ask the user for a password in this session: the cc-askpass plugin shows a password ' +
45  'prompt inside Claude Code whenever sudo (or ssh/git) needs one. Run sudo commands normally. ' +
46  'Do not add `-n` or `-S`, do not pipe a password into sudo, and do not ask the user to run sudo ' +
47  'themselves. If the user cancels or the password is wrong, sudo fails as usual; report that.'
48// Windows has no sudo: only ssh and git ask there.
49const WINDOWS_NOTE =
50  'ssh and git can ask the user for a password or key passphrase in this session: the cc-askpass plugin shows a ' +
51  'prompt inside Claude Code. Run them normally and do not ask the user to type passwords themselves.'
52// Added while remote prompts are on: models add `-t` or ask the user to log in themselves.
53const REMOTE_NOTE =
54  ' sudo on hosts reached with `ssh` asks through the same prompt: run `ssh host sudo …` directly, ' +
55  'without `-t` or `-tt`, and do not ask the user to log in to the host themselves.'
56// Shown under the Claude line of every remote pane (spec §8): a process running as the user
57// can pose as a relay, so the person is the last check.
58const REMOTE_WARNING =
59  'A remote prompt can be raised by any program running as you. Answer only if you expected this command to need a password on this host.'
60// Shown in red under `Asked by:` when the request did not come through our helper.
61const UNVERIFIED_WARNING =
62  "This request did not come through cc-askpass's helper. Answer only if you know which program is asking."
63// Characters of a remote host chain the heading shows; the verified host at its end always stays.
64const MAX_LABEL_LENGTH = 120
65// Characters of a remote program's name the heading shows.
66const MAX_PROGRAM_LENGTH = 40
67// A relayed host chain as the wrapper writes it: host names joined by ` via `.
68const VIA_PATTERN = /^[A-Za-z0-9._@:%-]+( via [A-Za-z0-9._@:%-]+)*$/
69// Time for the band to drop its button and hand the keyboard back (verified at 150 ms).
70const BAND_RELEASE_MS = 150
71const PANE = 'askpass'
72// Width the pane asks for when docked, and the width its content is measured at.
73const PANE_WIDTH = 60
74// Rows the `$` and "Claude ran" lines may take: a long command must not push the field away.
75const MAX_SECTION_ROWS = 3
76// Writes only into a FIFO: the first test skips a vanished helper, the second catches
77// the FIFO being removed between that test and the open (`<>` would create a file).
78// Read-write so the open never blocks.
79const REPLY_SCRIPT = '[ -p "$1" ] || exit 1; exec 3<>"$1"; [ -p /dev/fd/3 ] || { rm -f "$1"; exit 1; }; cat >&3'
80
81type Request = {
82  id: number
83  prompt: string
84  requester: Requester
85  isRetry: boolean
86  // The Bash tool command that led to this request, when it says more than the sudo line.
87  claudeCommand: string | null
88  // Set before the first await of an answer, so Enter, Esc and the alive poll answer once.
89  isAnswered?: boolean
90  // Windows: the listener generation it came from; its id means nothing to any other listener.
91  generation?: number
92}
93
94// Module state. A hot reload starts it over and kills the serve child with the module.
95// The password lives only in `typed`, and only while its pane is open.
96let askpassPath = ''
97let sessionDir = ''
98// Native Windows (spec §5): a built helper and a named pipe instead of `sh`, `ps` and FIFOs.
99let isWin = false
100// Windows: the build directory, and the listener's pipe, reply token and own image path once it is ready.
101let winOutDir = ''
102// Windows: the store key of the person's own exported `ssh` function, put back when ours goes.
103// In the store, not a module variable: after a hot reload the env holds ours, not theirs.
104const INHERITED_BASH_SSH = 'inheritedBashSsh'
105let winListener = { pipe: '', token: '', exe: '' }
106// Windows requests whose asker went away; the listener says so instead of `kill -0`.
107const goneIds = new Set<number>()
108// The commands of PowerShell tool calls running now: the tool puts them on no command line.
109const runningPowerShell: string[] = []
110let didSetDisplay = false
111// True while the listener runs and the env points at it.
112let isListening = false
113// Bumped whenever a serve loop is replaced, so the old loop's end is not taken for a crash.
114let serveGeneration = 0
115let stopServe = () => {}
116// The running listener's loop, done once it has taken back what it set.
117let serving: Promise<void> = Promise.resolve()
118const queue: Request[] = []
119let current: Request | null = null
120let isAdvancing = false
121let typed = ''
122let isMasked = true
123// True once the person asks to see the clipped command lines whole.
124let isExpanded = false
125// True while the current request waits for the person to open its pane (narrow terminal).
126let isWaitingForOpen = false
127let aliveTimer: Timer | null = null
128const answered = new AnsweredPids()
129// New on every load of this module. A reload shows the same pending requests again, and an
130// Input keeps the text it held under its key while the new module knows none of it.
131const LOAD_ID = Math.random().toString(36).slice(2, 8)
132// Claude's latest Bash commands, to name the one a request comes from. Background commands
133// outlive their tool call, so these are kept by count, not by call.
134const recentBashCommands: string[] = []
135const RECENT_BASH_LIMIT = 50
136// The plugin's options, read in `register`; a change reloads the module.
137let isRemoteOn = true
138let excludeHosts: readonly string[] = []
139
140function log($: EngineInterface, text: string): void {
141  void $.ui.log(`cc-askpass: ${text}`, { to: 'debug' })
142}
143
144// Requests are checked one at a time, so the queue keeps the order they arrived in.
145let checking: Promise<void> = Promise.resolve()
146
147/** A line of the listener of `generation`, checked behind the lines before it. */
148function onRequestLine($: EngineInterface, line: string, generation: number): void {
149  // Windows ids restart at 1 with each listener: a replaced one's lines would name the new one's requests.
150  const isStale = () => isWin && generation !== serveGeneration
151  checking = checking
152    .then(() => (isStale() ? undefined : isWin ? verifyWin($, line, generation) : verify($, line)))
153    .catch(error => {
154      // ps missing (a slim container) or failing: say so, and let the helper fail now.
155      $.ui.toast(`cc-askpass: could not check an askpass request: ${String(error)}`)
156      log($, `request failed: ${String(error)}`)
157      if (isStale()) return
158      const parsed = isWin ? parseWinLine(line) : parseRequest(line)
159      if (parsed !== null && !('gone' in parsed)) void reply($, 'id' in parsed ? parsed.id : parsed.helperPid, '')
160    })
161}
162
163/** Queues a Windows request: the listener already named who connected. */
164async function verifyWin($: EngineInterface, line: string, generation: number): Promise<void> {
165  const parsed = parseWinLine(line)
166  if (parsed === null) return log($, `ignored malformed request line: ${line}`)
167  if ('gone' in parsed) {
168    // The alive poll and the queue drop it.
169    goneIds.add(parsed.gone)
170    return
171  }
172  // The kernel names a client by its resolved image path, as it named the listener itself.
173  const requester = winRequester(parsed, winListener.exe)
174  const request: Request = {
175    id: parsed.id,
176    prompt: parsed.prompt,
177    requester,
178    isRetry: false,
179    claudeCommand: null,
180    generation,
181  }
182  return enqueue($, request, parsed.relay?.via ?? null, parsed.relay?.reported ?? null, parsed.ancestors)
183}
184
185/** Works out who asks with `ps` and queues the request; no caller is refused, the person decides. */
186async function verify($: EngineInterface, line: string): Promise<void> {
187  const parsed = parseRequest(line)
188  if (parsed === null) return log($, `ignored malformed request line: ${line}`)
189
190  const helperPs = await $.process.run(['ps', '-ww', '-o', 'ppid=,args=', '-p', String(parsed.helperPid)])
191  const helper = parseHelperPs(helperPs.stdout)
192  if (helper === null) return
193  const id = parsed.helperPid
194  let requester: Requester
195  if (!isOurHelper(helper.args, askpassPath)) {
196    log($, `request from pid ${id} did not come through our helper: ${helper.args}`)
197    requester = {
198      pid: id,
199      isSudo: false,
200      comm: 'unknown program',
201      command: stripControl(helper.args),
202      askedBy: { kind: 'unverified', exe: null },
203    }
204  } else {
205    const parentPs = await $.process.run(['ps', '-ww', '-o', 'user=,comm=,args=', '-p', String(helper.ppid)])
206    const parent = parseParentPs(parentPs.stdout, helper.ppid)
207    if (parent === null) return
208    requester = { ...parent, askedBy: await programOf($, parent) }
209  }
210  const request: Request = { id, prompt: parsed.prompt, requester, isRetry: false, claudeCommand: null }
211  return enqueue($, request, parsed.via, parsed.reported, [])
212}
213
214/**
215 * Cleans a relayed request's remote text, names the command Claude ran and queues the request.
216 * `via` and `reported` are a relay's raw fields; `ancestors` the Windows client's.
217 */
218async function enqueue(
219  $: EngineInterface,
220  request: Request,
221  via: string | null,
222  reported: string | null,
223  ancestors: WinRequest['ancestors'],
224): Promise<void> {
225  const { requester } = request
226  if (requester.askedBy.kind === 'relay') {
227    // With remote prompts off no relay is taken: the helper is ours, so an empty answer makes it fail at once.
228    if (!isRemoteOn) {
229      log($, 'rejected a remote request: remoteHosts is off')
230      return reply($, request.id, '')
231    }
232    // Everything but the host is remote text: it may not drive the terminal.
233    const clean = (text: string | null) => (text === null ? null : stripControl(text) || null)
234    // The wrapper writes host names only: any other chain is dropped, not shown.
235    const hosts = via !== null && VIA_PATTERN.test(via) ? via : null
236    requester.remote = { host: requester.askedBy.host, via: hosts, reported: clean(reported) }
237  }
238  // Any program can raise a pane, so no prompt may drive the terminal either.
239  request.prompt = stripControl(request.prompt)
240
241  const claudeCommand = await findClaudeCommand($, requester, ancestors)
242  // A local line that repeats the sudo command says nothing new; a remote pane always names it.
243  const isRepeat = requester.remote === undefined && claudeCommand?.trim() === requester.command.trim()
244  request.claudeCommand = isRepeat ? null : claudeCommand
245  const key = retryKey(request)
246  request.isRetry = key !== null && answered.has(key, await $.clock.now())
247  queue.push(request)
248  // The checks are done: a pane that fails to open leaves the request for /askpass.
249  void showNext($).catch(error => log($, `could not show the request: ${String(error)}`))
250}
251
252/** What the helper's parent is: our ssh wrapper's relay, or a program named by its executable. */
253async function programOf($: EngineInterface, parent: Omit<Requester, 'askedBy'>): Promise<AskSource> {
254  const host = relayHost(parent.command, `${$.plugin.root}/bin/shim/ssh`)
255  if (host !== null) return { kind: 'relay', host, realSsh: null }
256  // The path of the parent's executable (bin/exe-of).
257  const exe = await $.process.run(['sh', `${$.plugin.root}/bin/exe-of`, String(parent.pid)])
258  const path = exe.exitCode === 0 ? parseExePath(exe.stdout) : null
259  return { kind: 'program', exe: stripControl(path ?? (parent.isSudo ? 'sudo (root)' : parent.comm)) }
260}
261
262/** The command Claude ran that led to `requester`: on Windows from the client's `ancestors`. */
263async function findClaudeCommand(
264  $: EngineInterface,
265  requester: Requester,
266  ancestors: WinRequest['ancestors'],
267): Promise<string | null> {
268  if (isWin) {
269    const command = winClaudeCommand(ancestors, recentBashCommands, runningPowerShell)
270    return command ?? (await winMsysClaudeCommand($, ancestors))
271  }
272  const claudePid = claudePidOf(sessionDir)
273  if (claudePid === null || recentBashCommands.length === 0) return null
274  try {
275    const ps = await $.process.run(['ps', '-e', '-ww', '-o', 'pid=,ppid=,args='])
276    const shell = bashShellArgs(parsePsTable(ps.stdout), requester.pid, claudePid)
277    return shell === null ? null : matchBashCommand(shell, recentBashCommands)
278  } catch (error) {
279    // Only context for the pane: a failure here must not block the request.
280    log($, `could not find the Bash command: ${String(error)}`)
281    return null
282  }
283}
284
285/** A Bash call whose Windows ancestors stop at a script Git Bash forked: found by the MSYS parents. */
286async function winMsysClaudeCommand($: EngineInterface, ancestors: WinRequest['ancestors']): Promise<string | null> {
287  const sh = winMsysShell(ancestors)
288  if (sh === null || recentBashCommands.length === 0) return null
289  try {
290    const pids = ancestors.toReversed().map(({ pid }) => String(pid))
291    const walk = await $.process.run([sh, `${$.plugin.root}\\bin\\msys-parents`, ...pids])
292    for (const line of walk.stdout.split('\n').toReversed()) {
293      const command = matchBashCommand(line, recentBashCommands)
294      if (command !== null) return command
295    }
296    return null
297  } catch (error) {
298    // Only context for the pane: a failure here must not block the request.
299    log($, `could not walk the MSYS parents: ${String(error)}`)
300    return null
301  }
302}
303
304async function isAlive($: EngineInterface, pid: number): Promise<boolean> {
305  // The shell's builtin kill: /bin/kill may not be installed.
306  return (await $.process.run(['sh', '-c', 'kill -0 "$1"', '_', String(pid)])).exitCode === 0
307}
308
309/** A relayed request also ends with its relay: nothing is left to carry the answer back. */
310async function isRequestAlive($: EngineInterface, request: Request): Promise<boolean> {
311  // The Windows listener reports a gone asker, relay included: its connection is the request.
312  // An ended or replaced listener took every connection with it.
313  if (isWin) return isListening && request.generation === serveGeneration && !goneIds.has(request.id)
314  if (!(await isAlive($, request.id))) return false
315  return request.requester.remote === undefined || isAlive($, request.requester.pid)
316}
317
318/**
319 * What answered requests are remembered by, to tell a retry; only sudo asks again for the
320 * same thing. Locally sudo's pid. A relayed line carries only the remote helper's pid, new on
321 * every ask, so a remote sudo goes by its relay (one per ssh command), the command the remote
322 * side reported and the prompt.
323 */
324function retryKey(request: Request): number | string | null {
325  const { remote, isSudo, pid } = request.requester
326  if (remote === undefined) return isSudo ? pid : null
327  if (remoteProgram(remote.reported) !== 'sudo') return null
328  return `${remote.host}:${pid}:${remote.reported}:${request.prompt}`
329}
330
331/** The program a remote request names: the reported command's first word, or "a program". */
332function remoteProgram(reported: string | null): string {
333  const first = (reported ?? '').split(' ')[0] ?? ''
334  return (first.split('/').at(-1) ?? '').slice(0, MAX_PROGRAM_LENGTH) || 'a program'
335}
336
337/** The remote host chain, its start cut when long: the verified host at its end stays. */
338function hostLabel(remote: NonNullable<Requester['remote']>): string {
339  const label = remoteLabel(remote.host, remote.via)
340  return label.length > MAX_LABEL_LENGTH ? `…${label.slice(1 - MAX_LABEL_LENGTH)}` : label
341}
342
343/** Who waits for the password, for the band. */
344function askerName(requester: Requester): string {
345  const { remote } = requester
346  return remote === undefined ? requester.comm : `${remoteProgram(remote.reported)} on ${remote.host}`
347}
348
349function isMaskedByDefault(request: Request): boolean {
350  const { remote, isSudo } = request.requester
351  // A remote sudo is masked whatever its prompt says, as sudo is locally.
352  return shouldMask(remote === undefined ? isSudo : remoteProgram(remote.reported) === 'sudo', request.prompt)
353}
354
355function paneTitle(requester: Requester): string {
356  if (requester.remote !== undefined) return `Password for ${requester.remote.host}`
357  return requester.isSudo ? 'sudo password' : `Password requested by ${requester.comm} (pid ${requester.pid})`
358}
359
360/** The terminal outside fullscreen reports no clicks, so hints there name keys only. */
361function isMainScreen(e: { viewport?: { isFullscreen?: boolean } }): boolean {
362  return e.viewport?.isFullscreen === false
363}
364
365function focusWarning(isMain: boolean): string {
366  const how = isMain ? 'Press ctrl+x tab' : 'Click this pane (or press ctrl+x tab)'
367  return `${how} before typing — your keys are going to the prompt below.`
368}
369
370function askedByText(requester: Requester): string {
371  return `Asked by: ${askedByLine(requester.askedBy)}`
372}
373
374function isUnverified(requester: Requester): boolean {
375  return requester.askedBy.kind === 'unverified'
376}
377
378function claudeLine(request: Request, width = PANE_WIDTH, maxRows = MAX_SECTION_ROWS): string | null {
379  // A remote pane always has the line: what Claude Code recorded is the one thing no process
380  // can change, and "(unknown)" says nothing vouches for the request.
381  const command = request.claudeCommand ?? (request.requester.remote === undefined ? null : '(unknown)')
382  if (command === null) return null
383  return clipLines(`Claude ran: ${command}`, maxRows, width)
384}
385
386function commandLine(request: Request, width = PANE_WIDTH, maxRows = MAX_SECTION_ROWS): string {
387  const { remote } = request.requester
388  if (remote === undefined) return clipLines(`$ ${request.requester.command}`, maxRows, width)
389  if (remote.reported === null) return `$ (not reported by ${remote.host})`
390  // Only the reported text is cut: who reported it must stay in view.
391  const suffix = ` (reported by ${remote.host})`
392  const room = Math.max(1, maxRows * width - suffix.length)
393  const line = `$ ${remote.reported}`
394  return `${line.length > room ? `${line.slice(0, room - 1)}…` : line}${suffix}`
395}
396
397/** True when the Claude or `$` line is cut, so the pane offers them whole. */
398function isClipped(request: Request, width = PANE_WIDTH): boolean {
399  return (
400    claudeLine(request, width) !== claudeLine(request, width, Infinity) ||
401    commandLine(request, width) !== commandLine(request, width, Infinity)
402  )
403}
404
405/**
406 * Rows the pane body needs, measured at PANE_WIDTH (an inline pane is usually wider, so this
407 * errs tall). The focus warning is counted always: whether it shows is known only when drawn.
408 */
409function paneRows(request: Request): number {
410  const claude = claudeLine(request)
411  // The `remote` tag and the warning under the Claude line.
412  const remote = request.requester.remote === undefined ? 0 : 1 + wrappedLines(REMOTE_WARNING, PANE_WIDTH)
413  const header =
414    wrappedLines(paneHeading(request.requester), PANE_WIDTH) +
415    wrappedLines(askedByText(request.requester), PANE_WIDTH) +
416    (isUnverified(request.requester) ? wrappedLines(UNVERIFIED_WARNING, PANE_WIDTH) : 0) +
417    remote +
418    (claude === null ? 0 : wrappedLines(claude, PANE_WIDTH)) +
419    wrappedLines(commandLine(request), PANE_WIDTH) +
420    (isClipped(request) ? 1 : 0)
421  const field = (request.isRetry ? 1 : 0) + wrappedLines(promptLabel(request.prompt), PANE_WIDTH) + 1
422  const blankLines = 3
423  return wrappedLines(focusWarning(false), PANE_WIDTH) + header + field + 1 + blankLines
424}
425
426function paneHeading(requester: Requester): string {
427  const { remote } = requester
428  if (remote !== undefined) {
429    const program = remoteProgram(remote.reported)
430    return `${program} on ${hostLabel(remote)} needs ${program === 'sudo' ? 'your' : 'a'} password`
431  }
432  return requester.isSudo ? 'sudo needs your password' : `${requester.comm} (pid ${requester.pid}) needs a password`
433}
434
435function clearWaiting($: EngineInterface): void {
436  if (!isWaitingForOpen) return
437  isWaitingForOpen = false
438  $.ui.invalidate('ui.render')
439}
440
441async function openPane($: EngineInterface): Promise<void> {
442  if (current === null) return
443  const opened = await $.ui.open({
444    id: PANE,
445    title: paneTitle(current.requester),
446    focus: true,
447    closeOnEscape: true,
448    // Sizes the body wants: rows when inline above the prompt, columns when docked.
449    rows: paneRows(current),
450    columns: PANE_WIDTH,
451  })
452  if (opened.isPlaced) return clearWaiting($)
453  // An open the person did not ask for waits on a narrow terminal. The band's button and
454  // /askpass are opens they ask for, which are placed at any width.
455  isWaitingForOpen = true
456  $.ui.invalidate('ui.render')
457}
458
459/**
460 * Opens the pane from the band's button. While the band holds the keyboard a pane's focus
461 * request is refused, so the band is cleared first and the pane opens once it has let go.
462 * The press handler returns this promise: an open after the press is over counts as one
463 * the person did not ask for, and waits unplaced on a narrow terminal.
464 */
465async function openFromBand($: EngineInterface): Promise<void> {
466  clearWaiting($)
467  await $.clock.sleep(BAND_RELEASE_MS)
468  await openPane($)
469}
470
471/** Shows the next queued request whose helper is still alive; one caller at a time. */
472async function showNext($: EngineInterface): Promise<void> {
473  if (current !== null || isAdvancing) return
474  isAdvancing = true
475  try {
476    for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
477      if (!(await isRequestAlive($, next))) continue
478      const request = next
479      current = request
480      typed = ''
481      isMasked = isMaskedByDefault(request)
482      isExpanded = false
483      // A pane still open from before a reload is not drawn again by `ui.open`.
484      $.ui.invalidate('ui.render')
485      aliveTimer = $.clock.every(ALIVE_POLL_MS, () => {
486        void isRequestAlive($, request)
487          .then(alive => (alive || request.isAnswered ? undefined : finish($, request)))
488          .catch(error => log($, `alive check failed: ${String(error)}`))
489      })
490      await openPane($)
491      return
492    }
493  } finally {
494    isAdvancing = false
495  }
496  // A request queued while the loop above was finishing its last check.
497  if (current === null && queue.length > 0) await showNext($)
498}
499
500/** Ends `request` (answered or not) and moves on to the next one; a no-op once it ended. */
501async function finish($: EngineInterface, request: Request): Promise<void> {
502  if (current !== request) return
503  aliveTimer?.cancel()
504  aliveTimer = null
505  current = null
506  typed = ''
507  clearWaiting($)
508  await $.ui.close({ id: PANE })
509  await showNext($)
510}
511
512/**
513 * Answers Windows request `id` through `cc-askpass.exe --reply`, which hands it to the listener's
514 * reply pipe. Exit 0 means written, not accepted: a wrong token or an ended request is dropped there.
515 */
516async function winReply($: EngineInterface, id: number, password: string): Promise<void> {
517  // The reply is one line: a line break would cut the password short, so it is sent as a cancel.
518  if (/[\r\n]/.test(password)) {
519    log($, `the password for request ${id} had a line break; answered empty`)
520    password = ''
521  }
522  try {
523    const result = await $.process.run([askpassPath, '--reply'], {
524      stdin: `${winListener.token}\t${id}\n${password}\n`,
525      env: { CC_ASKPASS_PIPE: winListener.pipe },
526      timeoutMs: REPLY_TIMEOUT_MS,
527    })
528    if (result.exitCode !== 0) log($, `reply to request ${id} failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`)
529  } catch (error) {
530    log($, `reply to request ${id} failed: ${String(error)}`)
531  }
532}
533
534async function reply($: EngineInterface, id: number, password: string): Promise<void> {
535  if (isWin) return winReply($, id, password)
536  try {
537    await $.process.run(['sh', '-c', REPLY_SCRIPT, '_', `${sessionDir}/reply.${id}`], {
538      stdin: `${password}\n`,
539      timeoutMs: REPLY_TIMEOUT_MS,
540    })
541  } catch (error) {
542    log($, `reply to pid ${id} failed: ${String(error)}`)
543  }
544}
545
546async function submit($: EngineInterface, request: Request, password: string): Promise<void> {
547  if (current !== request || request.isAnswered) return
548  request.isAnswered = true
549  await reply($, request.id, password)
550  const key = retryKey(request)
551  if (key !== null) answered.mark(key, await $.clock.now())
552  await finish($, request)
553}
554
555async function setEnv($: EngineInterface): Promise<void> {
556  // Where the helper finds the listener goes first. Windows has no sudo and no DISPLAY check.
557  if (isWin) {
558    await $.env.set('CC_ASKPASS_PIPE', winListener.pipe)
559  } else {
560    await $.env.set('CC_ASKPASS_DIR', sessionDir)
561    await $.env.set('SUDO_ASKPASS', askpassPath)
562  }
563  await $.env.set('SSH_ASKPASS', askpassPath)
564  await $.env.set('SSH_ASKPASS_REQUIRE', 'force')
565  // Read by bin/shim/ssh, one glob per line.
566  await $.env.set('CC_ASKPASS_EXCLUDE', excludeHosts.join('\n'))
567  // remoteHosts: the wrapper stays on PATH all session and checks this on every call.
568  await $.env.set('CC_ASKPASS_WRAP', isRemoteOn ? '1' : undefined)
569  if (isWin) return
570  // sudo only uses askpass when DISPLAY is set; a real display is left alone. The marker
571  // survives a hot reload (module variables do not), so ownership of :0 is not forgotten.
572  const display = await $.env.get('DISPLAY')
573  const isOurs = display === ':0' && (await $.env.get('CC_ASKPASS_SET_DISPLAY')) === '1'
574  if (display === undefined || isOurs) {
575    await $.env.set('DISPLAY', ':0')
576    await $.env.set('CC_ASKPASS_SET_DISPLAY', '1')
577    didSetDisplay = true
578  }
579}
580
581async function unsetEnv($: EngineInterface): Promise<void> {
582  isListening = false
583  if (isWin) {
584    await $.env.set('CC_ASKPASS_PIPE', undefined)
585  } else {
586    await $.env.set('CC_ASKPASS_DIR', undefined)
587    await $.env.set('SUDO_ASKPASS', undefined)
588  }
589  await $.env.set('SSH_ASKPASS', undefined)
590  await $.env.set('SSH_ASKPASS_REQUIRE', undefined)
591  await $.env.set('CC_ASKPASS_EXCLUDE', undefined)
592  await $.env.set('CC_ASKPASS_WRAP', undefined)
593  if (didSetDisplay) {
594    await $.env.set('DISPLAY', undefined)
595    await $.env.set('CC_ASKPASS_SET_DISPLAY', undefined)
596    didSetDisplay = false
597  }
598}
599
600/**
601 * The Windows helper's build directory, `win\out\<hash of its source>`, compiled first when it
602 * holds no `cc-askpass.exe`; otherwise why it could not be built (a missing source counts too).
603 */
604async function buildHelper($: EngineInterface, root: string): Promise<{ outDir: string } | { error: string }> {
605  try {
606    const source = `${root}\\win\\cc-askpass.cs`
607    const outDir = `${root}\\win\\out\\${fnv1a64(await $.fs.read(source))}`
608    if (await $.fs.exists(`${outDir}\\cc-askpass.exe`)) return { outDir }
609    // Windows PowerShell 5.1, whatever the tools run: its Add-Type is the .NET Framework compiler.
610    const systemRoot = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
611    const powershell = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
612    // A run that throws (a timeout) is a failed build like any other.
613    const build = await $.process
614      .run(
615        [powershell, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${root}\\win\\build.ps1`, source, outDir],
616        { timeoutMs: BUILD_TIMEOUT_MS },
617      )
618      .catch((error: unknown) => ({ exitCode: -1, stderr: String(error) }))
619    // A build of the same source by another session starting at once may have finished it.
620    if (build.exitCode === 0 || (await $.fs.exists(`${outDir}\\cc-askpass.exe`))) return { outDir }
621    // build.ps1 prints one line; PowerShell's own errors put the reason first.
622    const reason = build.stderr.trim().split(/\r?\n/)[0]?.replace(/^cc-askpass: build failed: /, '')
623    return { error: reason || `exit code ${build.exitCode}` }
624  } catch (error) {
625    return { error: String(error) }
626  }
627}
628
629/**
630 * Why the built helper cannot run (AppLocker, WDAC or an antivirus blocks it), or null when it
631 * runs: bare and without a listener, a working helper says CC_ASKPASS_PIPE is not set.
632 */
633async function helperRunError($: EngineInterface, exe: string): Promise<string | null> {
634  try {
635    const run = await $.process.run([exe], { env: { CC_ASKPASS_PIPE: '' }, timeoutMs: REPLY_TIMEOUT_MS })
636    if (run.exitCode === 1 && run.stderr.includes('CC_ASKPASS_PIPE is not set')) return null
637    return run.stderr.trim().split(/\r?\n/)[0] || `exit code ${run.exitCode}`
638  } catch (error) {
639    return String(error)
640  }
641}
642
643/**
644 * Builds the Windows helper, checks that it runs and gives bash our ssh; false, toasted, when
645 * either failed: a shim that cannot run would break every ssh.
646 */
647async function startWindows($: EngineInterface): Promise<boolean> {
648  const built = await buildHelper($, $.plugin.root)
649  if ('error' in built) {
650    $.ui.toast(`cc-askpass: could not build the Windows helper: ${built.error}`)
651    return false
652  }
653  const error = await helperRunError($, `${built.outDir}\\cc-askpass.exe`)
654  if (error !== null) {
655    $.ui.toast(`cc-askpass: could not run the Windows helper: ${error}`)
656    return false
657  }
658  // A second start replaces a running listener only now that its own helper runs. The old one
659  // ends first: ending later, it could take back the shim set below.
660  if (serveGeneration > 0) {
661    serveGeneration++
662    stopServe()
663    // Its last env writes land before they are taken back.
664    await serving
665    await endWinListener($)
666  }
667  winOutDir = built.outDir
668  const before = await $.env.get('BASH_FUNC_ssh%%')
669  // An earlier load's function is ours, and the person's is already in the store; anything
670  // else is the person's and comes back later.
671  if (before === undefined) await $.store.delete(INHERITED_BASH_SSH)
672  else if (!isBashSshFunction(before)) await $.store.set(INHERITED_BASH_SSH, before)
673  // Git Bash's profile puts its /usr/bin (and its ssh) before the inherited PATH, so every bash
674  // imports this as an `ssh` function instead; the two variables stop it rewriting `/x` arguments
675  // into Windows paths.
676  await $.env.set('BASH_FUNC_ssh%%', bashSshFunction(`${winOutDir}\\shim\\ssh.exe`))
677  return true
678}
679
680/**
681 * Takes our ssh back off PATH and out of bash once the listener never started or ended. The Bash
682 * tool's snapshot keeps the function, where the wrapper passes through: CC_ASKPASS_WRAP is unset.
683 */
684async function removeWinShim($: EngineInterface): Promise<void> {
685  const inherited = await $.store.get(INHERITED_BASH_SSH)
686  await $.env.set('BASH_FUNC_ssh%%', typeof inherited === 'string' ? inherited : undefined)
687  await $.env.set('PATH', withoutShim(await $.env.get('PATH'), `${winOutDir}\\shim`, ';'))
688}
689
690/**
691 * Drops every Windows request: they went with the listener that held their connections, and no
692 * `gone` line will come. A new listener numbers its requests from 1; the askers send theirs anew.
693 */
694async function dropWinRequests($: EngineInterface): Promise<void> {
695  goneIds.clear()
696  queue.length = 0
697  if (current !== null) await finish($, current)
698}
699
700/** Everything the Windows listener set, and its requests, gone with it. */
701async function endWinListener($: EngineInterface): Promise<void> {
702  // Its env only once it was ready: an inherited SSH_ASKPASS it never replaced stays.
703  if (isListening) await unsetEnv($)
704  await dropWinRequests($)
705  await removeWinShim($)
706}
707
708async function runServe($: EngineInterface): Promise<void> {
709  stopServe()
710  const generation = ++serveGeneration
711  askpassPath = isWin ? `${winOutDir}\\cc-askpass.exe` : `${$.plugin.root}/bin/askpass`
712  if (isWin) await dropWinRequests($)
713  const serve = $.process.spawn({ argv: [askpassPath, isWin ? 'serve' : '--serve', await $.session.id()] })
714  stopServe = () => void serve.return(undefined as never)
715
716  let isReady = false
717  let isDone = false
718  let didGiveUp = false
719  let lastError = ''
720  const notStarted = () => $.ui.toast(`cc-askpass: the askpass listener did not start${lastError && `: ${lastError}`}`)
721  // Never cancelled, only guarded: a cancelled timer slows every later `settle()` of the test
722  // kit's mock clock by a second (Claude Code 2.1.296).
723  $.clock.after(READY_TIMEOUT_MS, () => {
724    if (isReady || isDone || generation !== serveGeneration) return
725    didGiveUp = true
726    notStarted()
727    stopServe()
728  })
729
730  let rest = ''
731  try {
732    for await (const chunk of serve) {
733      if (chunk.stream !== 'stdout') {
734        lastError = chunk.text.trim()
735        log($, chunk.text)
736        continue
737      }
738      const split = splitLines(rest, chunk.text)
739      rest = split.rest
740      for (const line of split.lines) {
741        if (isReady) {
742          onRequestLine($, line, generation)
743          continue
744        }
745        const ready = isWin ? parseWinReady(line) : parseReady(line)
746        if (ready === null) continue
747        isReady = true
748        if (typeof ready === 'string') sessionDir = ready
749        else winListener = ready
750        await setEnv($)
751        isListening = true
752      }
753    }
754  } catch (error) {
755    log($, `askpass listener failed: ${String(error)}`)
756  }
757  isDone = true
758
759  if (generation !== serveGeneration) return
760  if (!isReady) {
761    if (!didGiveUp) notStarted()
762    if (isWin) await removeWinShim($)
763    return
764  }
765  if (isWin) await endWinListener($)
766  else await unsetEnv($)
767  $.ui.toast(`cc-askpass: the askpass listener stopped; ${isWin ? 'ssh' : 'sudo'} will not ask for passwords here`)
768}
769
770export const register: Register = (on, options) => {
771  isRemoteOn = options.remoteHosts !== false
772  const globs: unknown = options.excludeHosts
773  excludeHosts = Array.isArray(globs) ? globs.filter((glob): glob is string => typeof glob === 'string') : []
774
775  on('session.start', async ($, e, next) => {
776    // Under -p or the SDK nobody can answer a pane: leave sudo to fail at once as before.
777    if (!e.isInteractive) return next(e)
778    isWin = (await $.env.get('OS')) === 'Windows_NT'
779    // Before the session's shell snapshot, which fixes PATH for every Bash command: a command
780    // that changed PATH itself would lose the permission dialog's "don't ask again".
781    if (isWin && !(await startWindows($))) return next(e)
782    const shim = isWin ? `${winOutDir}\\shim` : `${$.plugin.root}/bin/shim`
783    const path = withShim(await $.env.get('PATH'), shim, isWin ? ';' : ':')
784    if (path !== null) await $.env.set('PATH', path)
785    const started = await next(e)
786    await $.command.register({
787      name: 'askpass',
788      description: 'Open the pending password prompt',
789      immediate: true,
790    })
791    serving = runServe($).catch(error => log($, `askpass listener failed: ${String(error)}`))
792    return started
793  })
794
795  on('prompt.compose', async ($, e, next) => {
796    const composed = await next(e)
797    if (!isListening) return composed
798    const note = isWin ? WINDOWS_NOTE : SUDO_NOTE
799    const text = isRemoteOn ? note + REMOTE_NOTE : note
800    return { sections: [...composed.sections, { id: 'cc-askpass:sudo', text, scope: 'session' as const }] }
801  })
802
803  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
804    if (e.tool === 'Bash') {
805      recentBashCommands.push(e.command)
806      if (recentBashCommands.length > RECENT_BASH_LIMIT) recentBashCommands.shift()
807    }
808    return next(e)
809  })
810
811  // A request's ancestors show that the PowerShell tool runs, not what: while exactly one call
812  // runs, that call is the one (spec §7). The tool has no typed arguments, so it is matched by pattern.
813  on('tool.call', { tool: /^PowerShell$/ }, async ($, e, next) => {
814    const { command } = e as { command?: unknown }
815    if (typeof command !== 'string') return next(e)
816    runningPowerShell.push(command)
817    try {
818      return await next(e)
819    } finally {
820      runningPowerShell.splice(runningPowerShell.indexOf(command), 1)
821    }
822  })
823
824  on('session.end', async ($, e, next) => {
825    // After /clear or an in-process resume the process goes on under a new session id and
826    // no session.start follows, so the listener and the env pointing at its dir stay.
827    // The Windows listener leaves no files: it ends with Claude Code.
828    const isProcessEnding = e.reason !== 'clear' && e.reason !== 'resume'
829    if (!isWin && isProcessEnding && sessionDir !== '') await $.process.run(['rm', '-rf', sessionDir])
830    return next(e)
831  })
832
833  on('command.run', { command: 'askpass' }, async $ => {
834    if (current === null) {
835      $.ui.toast('No pending password request')
836      return { text: 'No pending password request.' }
837    }
838    await openPane($)
839    return { text: 'Password prompt opened.' }
840  })
841
842  // Esc or the close mark: answer empty so the helper exits and sudo gives up.
843  on('ui.close', async ($, e, next) => {
844    const request = current
845    const closed = await next(e)
846    if (e.id === PANE && e.origin.kind === 'person' && request !== null) await submit($, request, '')
847    return closed
848  })
849
850  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
851    const request = current
852    if (!isWaitingForOpen || request === null || e.props.hasSurvey) return next(e)
853    const { Box, Text, Button } = $.ui.resolve(e)
854    const howToOpen = isMainScreen(e)
855      ? 'press ctrl+x tab then Enter, or type /askpass'
856      : 'click the button below or type /askpass'
857    // The hint has a line of its own: beside the button it wraps mid-sentence.
858    return (
859      <Box flexDirection="column">
860        <Text>
861          {askerName(request.requester)} is waiting for a password — {howToOpen}
862        </Text>
863        <Box>
864          {/* ctrl+x tab hands the band the keys; the ring starts here so Enter presses it. */}
865          <Button key="open-askpass" variant="primary" autoFocus onPress={() => openFromBand($)}>
866            Open password prompt
867          </Button>
868        </Box>
869      </Box>
870    )
871  })
872
873  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
874    const elements = $.ui.resolve(e)
875    const { Box, Text, Button } = elements
876    const request = current
877    if (request === null) return <Text dimColor>No pending password request.</Text>
878    // Mobile has no Input; its table hands back a stand-in that draws nothing.
879    if (e.surface === 'mobile' || !('Input' in elements)) {
880      return <Text>Answer this prompt in the terminal or the desktop app.</Text>
881    }
882    const { Input } = elements
883
884    // A fresh key per request and per load: an Input keeps its old text when a key is reused.
885    const key = `password-${request.id}-${LOAD_ID}`
886    const warning = e.props.isFocused ? null : focusWarning(isMainScreen(e))
887    // Clipped at the body's own width, less the paddingX on each side. Expanded, the pane
888    // keeps its size and its body scrolls.
889    const width = Math.max(1, e.props.bodyColumns - 2)
890    const maxRows = isExpanded ? Infinity : MAX_SECTION_ROWS
891    const claude = claudeLine(request, width, maxRows)
892    const isRemote = request.requester.remote !== undefined
893
894    return (
895      <Box flexDirection="column" paddingX={1}>
896        {warning !== null && (
897          <Text color="red" bold>
898            {warning}
899          </Text>
900        )}
901        <Text bold wrap="wrap">
902          {paneHeading(request.requester)}
903        </Text>
904        <Text dimColor wrap="wrap">
905          {askedByText(request.requester)}
906        </Text>
907        {isUnverified(request.requester) && (
908          <Text color="red" wrap="wrap">
909            {UNVERIFIED_WARNING}
910          </Text>
911        )}
912        {isRemote && <Text dimColor>remote</Text>}
913        <Text> </Text>
914        {claude !== null && (
915          <Text dimColor wrap="wrap">
916            {claude}
917          </Text>
918        )}
919        {isRemote && (
920          <Text dimColor wrap="wrap">
921            {REMOTE_WARNING}
922          </Text>
923        )}
924        <Text dimColor wrap="wrap">
925          {commandLine(request, width, maxRows)}
926        </Text>
927        {isClipped(request, width) && (
928          <Box>
929            <Button
930              key="expand"
931              onPress={() => {
932                isExpanded = !isExpanded
933                $.ui.invalidate('ui.render')
934              }}
935            >
936              {isExpanded ? 'Show less' : 'Show full command'}
937            </Button>
938          </Box>
939        )}
940        <Text> </Text>
941        {request.isRetry && <Text color="red">Sorry, try again.</Text>}
942        <Text wrap="wrap">{promptLabel(request.prompt)}</Text>
943        <Box flexDirection="row" columnGap={2}>
944          <Input
945            key={key}
946            autoFocus
947            value={isMasked ? '•'.repeat(typed.length) : typed}
948            submitLabel="send"
949            onInput={value => {
950              typed = isMasked ? applyMaskedInput(typed, value) : value
951              $.ui.invalidate('ui.render')
952            }}
953            onSubmit={value => {
954              const password = isMasked ? applyMaskedInput(typed, value) : value
955              void submit($, request, password)
956            }}
957          />
958          <Button
959            key="toggle"
960            onPress={() => {
961              isMasked = !isMasked
962              $.ui.invalidate('ui.render')
963            }}
964          >
965            {isMasked ? 'Show' : 'Hide'}
966          </Button>
967        </Box>
968        <Text> </Text>
969        <Box>
970          <Button key="cancel" role="dismiss" onPress={() => void submit($, request, '')}>
971            Cancel
972          </Button>
973        </Box>
974      </Box>
975    )
976  })
977}
978
hooks/core.ts 415 lines
1// Pure logic for the askpass mod: nothing here touches `$`.
2
3const ANSWERED_TTL_MS = 10 * 60 * 1000
4// Whole command lines are shown; this only stops a pathological argv from flooding the pane.
5const MAX_COMMAND_LENGTH = 2000
6
7/** Who a request says asked: shown to the person, never used to refuse it. */
8export type AskSource =
9  | { kind: 'program'; exe: string }
10  | { kind: 'relay'; host: string; realSsh: string | null }
11  | { kind: 'unverified'; exe: string | null }
12
13export type Requester = {
14  pid: number
15  isSudo: boolean
16  comm: string
17  command: string
18  askedBy: AskSource
19  // Set for a request relayed by our ssh wrapper: `host` from the wrapper's own args, the rest
20  // as the remote side reported it, control characters removed.
21  remote?: { host: string; via: string | null; reported: string | null }
22}
23
24/** The `Asked by:` text for a source. */
25export function askedByLine(source: AskSource): string {
26  if (source.kind === 'program') return source.exe
27  if (source.kind === 'relay') return `cc-askpass ssh wrapper → ${source.host}${source.realSsh === null ? '' : ` (${source.realSsh})`}`
28  return `${source.exe ?? 'unknown program'} (unverified)`
29}
30
31const firstLine = (out: string): string => out.split('\n')[0] ?? ''
32
33/** `args` cut to MAX_COMMAND_LENGTH, an ellipsis marking the cut. */
34const clipCommand = (args: string): string =>
35  args.length > MAX_COMMAND_LENGTH ? `${args.slice(0, MAX_COMMAND_LENGTH)}…` : args
36
37/** Splits streamed text into complete lines; `rest` is the unterminated tail. */
38export function splitLines(rest: string, chunk: string): { lines: string[]; rest: string } {
39  const lines = (rest + chunk).split('\n')
40  return { lines: lines.slice(0, -1), rest: lines.at(-1) ?? '' }
41}
42
43/** The session dir from the serve process's first line, `ready<TAB><dir>`. */
44export function parseReady(line: string): string | null {
45  return /^ready\t(.+)$/.exec(line)?.[1] ?? null
46}
47
48/**
49 * A helper's request line, `<pid><TAB><prompt>`, optionally followed by `<TAB><via><TAB><reported>`:
50 * the host chain and the command a relayed prompt claims. Empty optional fields become null.
51 */
52export function parseRequest(
53  line: string,
54): { helperPid: number; prompt: string; via: string | null; reported: string | null } | null {
55  const [, pid, prompt, via, reported] = /^(\d+)\t([^\t]*)(?:\t([^\t]*)(?:\t(.*))?)?$/.exec(line) ?? []
56  if (pid === undefined || prompt === undefined || Number(pid) <= 0) return null
57  return { helperPid: Number(pid), prompt, via: via || null, reported: reported || null }
58}
59
60/**
61 * The host of a process running our ssh wrapper as `[<interpreter> ]<shimPath> --relay <host>`;
62 * null for any other process or a host with characters outside what ssh hosts use.
63 */
64export function relayHost(args: string, shimPath: string): string | null {
65  const afterInterpreter = args.slice(args.indexOf(' ') + 1)
66  for (const candidate of [args, afterInterpreter]) {
67    if (!candidate.startsWith(`${shimPath} --relay `)) continue
68    const host = candidate.slice(shimPath.length + ' --relay '.length)
69    if (/^[A-Za-z0-9._@:%-]+$/.test(host)) return host
70  }
71  return null
72}
73
74/**
75 * A relayed request's label: the reported host chain, then the host we verified, which always
76 * comes last. The wrapper ends `via` with the hop it relays from, so that hop is not doubled.
77 */
78export function remoteLabel(host: string, via: string | null): string {
79  if (!via || via === host) return host
80  return via.endsWith(` via ${host}`) ? via : `${via} via ${host}`
81}
82
83/** `path` with `dir` first, or null when `dir` is already on it (a reload). */
84export function withShim(path: string | undefined, dir: string, separator: ':' | ';' = ':'): string | null {
85  if (path === undefined || path === '') return dir
86  return path.split(separator).includes(dir) ? null : `${dir}${separator}${path}`
87}
88
89/** `path` without `dir`: the Windows shim taken back off when the listener is gone. */
90export function withoutShim(path: string | undefined, dir: string, separator: ':' | ';'): string | undefined {
91  return path?.split(separator).filter(entry => entry !== dir).join(separator)
92}
93
94/**
95 * The `ssh` function every Git Bash imports from `BASH_FUNC_ssh%%`: it runs our wrapper with
96 * MSYS path conversion off. The path is single-quoted: `$` and backticks are legal in it.
97 */
98export function bashSshFunction(shimExe: string): string {
99  const quoted = `'${shimExe.replaceAll('\\', '/').replaceAll("'", `'\\''`)}'`
100  return `() {  MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' ${quoted} "$@"\n}`
101}
102
103/** Whether `text` is a function `bashSshFunction` wrote, for any shim path. */
104export function isBashSshFunction(text: string): boolean {
105  return /^\(\) \{ {2}MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='\*' '.*' "\$@"\n\}$/s.test(text)
106}
107
108/**
109 * 64-bit FNV-1a of `text`'s UTF-16 code units, low byte first, as 16 hex digits: names the
110 * Windows helper's build after its source.
111 */
112export function fnv1a64(text: string): string {
113  let hash = 0xcbf29ce484222325n
114  for (let i = 0; i < text.length; i++) {
115    const unit = text.charCodeAt(i)
116    for (const byte of [unit & 0xff, unit >> 8]) hash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn
117  }
118  return hash.toString(16).padStart(16, '0')
119}
120
121/**
122 * The Windows listener's first line, `ready<TAB><pipe name><TAB><32-digit token><TAB><helper path>`:
123 * the path is the listener's own image as the kernel names it, as it names the clients.
124 */
125export function parseWinReady(line: string): { pipe: string; token: string; exe: string } | null {
126  const [, pipe, token, exe] = /^ready\t(cc-askpass-[^\t\\]+)\t([0-9a-f]{32})\t([^\t\r]+)\r?$/.exec(line) ?? []
127  return pipe === undefined || token === undefined || exe === undefined ? null : { pipe, token, exe }
128}
129
130/** A request as the Windows listener describes it: who connected (pid from the kernel) and what it asked. */
131export type WinRequest = {
132  id: number
133  client: { pid: number; exe: string }
134  parent: { pid: number; exe: string; args: string } | null
135  // The client's ancestors from its parent up to Claude Code, for `Claude ran:`.
136  ancestors: { pid: number; args: string }[]
137  prompt: string
138  relay: { host: string; realSsh: string; remotePid: number; via: string | null; reported: string | null } | null
139}
140
141/** One line of the Windows listener: a request, or `{"gone":<id>}` when its asker went away. */
142export function parseWinLine(line: string): WinRequest | { gone: number } | null {
143  let value: Partial<WinRequest> & { gone?: unknown }
144  try {
145    value = JSON.parse(line)
146  } catch {
147    return null
148  }
149  if (typeof value?.gone === 'number') return { gone: value.gone }
150  const { parent, relay, ancestors } = value ?? {}
151  const isRequest =
152    Number.isInteger(value?.id) &&
153    isNumber(value.client?.pid) &&
154    isString(value.client.exe) &&
155    isString(value.prompt) &&
156    (parent === null || (isNumber(parent?.pid) && isString(parent.exe) && isString(parent.args))) &&
157    (relay === null ||
158      (isString(relay?.host) &&
159        isString(relay.realSsh) &&
160        isNumber(relay.remotePid) &&
161        isStringOrNull(relay.via) &&
162        isStringOrNull(relay.reported))) &&
163    Array.isArray(ancestors) &&
164    ancestors.every(ancestor => isNumber(ancestor?.pid) && isString(ancestor.args))
165  return isRequest ? (value as WinRequest) : null
166}
167
168const isNumber = (value: unknown): value is number => typeof value === 'number'
169const isString = (value: unknown): value is string => typeof value === 'string'
170const isStringOrNull = (value: unknown): value is string | null => value === null || isString(value)
171
172// Windows paths ignore case and take either slash.
173const isSamePath = (a: string, b: string): boolean =>
174  a.replaceAll('/', '\\').toLowerCase() === b.replaceAll('/', '\\').toLowerCase()
175
176/**
177 * Who a Windows request says asked: through our helper (`helperExe`, the path the listener
178 * reported for itself), the program that started it; through our ssh wrapper beside it, its
179 * relay; anything else, the client itself, unverified.
180 */
181export function winAskSource(request: WinRequest, helperExe: string): AskSource {
182  const { client, parent, relay } = request
183  const shimExe = helperExe.replace(/[^\\/]*$/, 'shim\\ssh.exe')
184  // File names may hold bidi and format characters: a path shown to expose a program must not
185  // disguise it. The host comes from the wrapper's arguments, which Claude chose.
186  if (isSamePath(client.exe, helperExe)) {
187    return parent === null ? { kind: 'unverified', exe: null } : { kind: 'program', exe: stripControl(parent.exe) }
188  }
189  if (isSamePath(client.exe, shimExe) && relay !== null) {
190    return { kind: 'relay', host: stripControl(relay.host), realSsh: stripControl(relay.realSsh) }
191  }
192  // An image path the listener could not read arrives empty.
193  return { kind: 'unverified', exe: stripControl(client.exe) || null }
194}
195
196/**
197 * A Windows request's Requester, naming the process `Asked by:` names: the program behind our
198 * helper, or else the client. `remote` is left to the caller, which cleans the relay's text.
199 */
200export function winRequester(request: WinRequest, helperExe: string): Requester {
201  const askedBy = winAskSource(request, helperExe)
202  const { client, parent } = request
203  const asker = askedBy.kind === 'program' && parent !== null ? parent : client
204  const args = stripControl(parent?.args ?? '')
205  return {
206    pid: asker.pid,
207    isSudo: false,
208    comm: (stripControl(asker.exe).split(/[\\/]/).at(-1) ?? '').replace(/\.exe$/i, '') || 'unknown program',
209    command: clipCommand(args),
210    askedBy,
211  }
212}
213
214/**
215 * The command Claude ran that led to a Windows request. A Bash call is on its shell's command
216 * line, matched from the outermost ancestor in; a PowerShell call is on none, so it is named
217 * only when an ancestor runs the PowerShell tool's launcher and exactly one call is running.
218 */
219export function winClaudeCommand(
220  ancestors: WinRequest['ancestors'],
221  recentBash: readonly string[],
222  runningPowerShell: readonly string[],
223): string | null {
224  for (const { args } of ancestors.toReversed()) {
225    const command = matchBashCommand(args, recentBash)
226    if (command !== null) return command
227  }
228  const isPowerShell = ancestors.some(({ args }) => args.includes('$__claudeCodeScript'))
229  return isPowerShell && runningPowerShell.length === 1 ? (runningPowerShell[0] ?? null) : null
230}
231
232/**
233 * Git's sh.exe, beside the outermost ancestor that runs an MSYS program (`…\usr\bin\x.exe`):
234 * it can walk the MSYS parents (bin/msys-parents) that Windows lost when Git Bash forked.
235 */
236export function winMsysShell(ancestors: WinRequest['ancestors']): string | null {
237  for (const { args } of ancestors.toReversed()) {
238    const exe = /^"([^"]+)"|^(\S+)/.exec(args)
239    const path = exe?.[1] ?? exe?.[2] ?? ''
240    if (/\\usr\\bin\\[^\\]+\.exe$/i.test(path)) return path.replace(/[^\\]+\.exe$/i, 'sh.exe')
241  }
242  return null
243}
244
245/**
246 * `text` without C0 and C1 control characters (ESC included) and the invisible format and
247 * bidi characters that reorder or hide text: remote text must not drive the terminal or
248 * disguise itself.
249 */
250export function stripControl(text: string): string {
251  return text.replace(
252    /[\u0000-\u001f\u007f-\u009f\u061c\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
253    '',
254  )
255}
256
257/** Output of `ps -o ppid=,args= -p <helper pid>`; the args keep the prompt's trailing space. */
258export function parseHelperPs(out: string): { ppid: number; args: string } | null {
259  const [, ppid, args] = /^\s*(\d+)\s+(.*)$/.exec(firstLine(out)) ?? []
260  return ppid === undefined || args === undefined ? null : { ppid: Number(ppid), args }
261}
262
263/**
264 * Whether a helper's args run our askpass script: the path must be the program or its
265 * interpreter's first argument, so a path smuggled into the prompt text does not count.
266 */
267export function isOurHelper(args: string, askpassPath: string): boolean {
268  const afterInterpreter = args.slice(args.indexOf(' ') + 1)
269  return [args, afterInterpreter].some(
270    candidate => candidate === askpassPath || candidate.startsWith(`${askpassPath} `),
271  )
272}
273
274/** Output of `ps -o user=,comm=,args= -p <pid>`; macOS prints the full path in `comm`. */
275export function parseParentPs(out: string, pid: number): Omit<Requester, 'askedBy'> | null {
276  const [, user, commPath, args] = /^\s*(\S+)\s+(\S+)\s+(.*?)\s*$/.exec(firstLine(out)) ?? []
277  if (user === undefined || commPath === undefined || args === undefined) return null
278  const comm = commPath.split('/').at(-1) ?? commPath
279  return { pid, isSudo: user === 'root' && comm === 'sudo', comm, command: clipCommand(args) }
280}
281
282/** A prompt for the line above the field, its trailing colon (and blanks) dropped. */
283export function promptLabel(prompt: string): string {
284  return prompt.replace(/\s*[::]?\s*$/, '')
285}
286
287/**
288 * Masks by default: prompts are localized, so only prompts known not to be secret
289 * (git's untranslated username prompt, ssh's host-key question) are shown in clear.
290 */
291export function shouldMask(isSudo: boolean, prompt: string): boolean {
292  return isSudo || !(/^Username for '/.test(prompt) || /\(yes\/no/.test(prompt))
293}
294
295/**
296 * The real text behind a masked field, from the field's new text. Bullets stand for
297 * characters already typed; other characters are new and replace whatever bullets are
298 * missing around them (a select-all paste replaces everything). With no new characters,
299 * the text got shorter, taken as deleted from the end.
300 */
301export function applyMaskedInput(real: string, shown: string): string {
302  const leading = Math.min(/^•*/.exec(shown)?.[0].length ?? 0, real.length)
303  const rest = shown.slice(leading)
304  const trailing = Math.min(/•*$/.exec(rest)?.[0].length ?? 0, real.length - leading)
305  const inserted = rest.slice(0, rest.length - trailing)
306  if (inserted === '') return real.slice(0, shown.length)
307  return real.slice(0, leading) + inserted + real.slice(real.length - trailing)
308}
309
310/**
311 * Sudo pids (and keys of remote requests) already answered: a second request from one means
312 * the password was wrong.
313 */
314export class AnsweredPids {
315  private readonly answeredAt = new Map<number | string, number>()
316
317  mark(pid: number | string, now: number): void {
318    this.prune(now)
319    this.answeredAt.set(pid, now)
320  }
321
322  has(pid: number | string, now: number): boolean {
323    this.prune(now)
324    return this.answeredAt.has(pid)
325  }
326
327  // Old entries go so a recycled pid is not mistaken for a retry.
328  private prune(now: number): void {
329    for (const [pid, at] of this.answeredAt) {
330      if (now - at > ANSWERED_TTL_MS) this.answeredAt.delete(pid)
331    }
332  }
333}
334
335/** The path of a process's resolved executable (bin/exe-of). */
336export function parseExePath(out: string): string | null {
337  return firstLine(out) || null
338}
339
340export type PsTable = Map<number, { ppid: number; args: string }>
341
342/** Output of `ps -e -ww -o pid=,ppid=,args=`. */
343export function parsePsTable(out: string): PsTable {
344  const table: PsTable = new Map()
345  for (const line of out.split('\n')) {
346    const [, pid, ppid, args] = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line) ?? []
347    if (pid !== undefined && ppid !== undefined && args !== undefined) {
348      table.set(Number(pid), { ppid: Number(ppid), args })
349    }
350  }
351  return table
352}
353
354/** The args of `pid`'s ancestor that Claude Code started: the Bash tool's shell, if any. */
355export function bashShellArgs(table: PsTable, pid: number, claudePid: number): string | null {
356  const seen = new Set<number>()
357  for (let current = table.get(pid); current !== undefined; current = table.get(current.ppid)) {
358    if (current.ppid === claudePid) return current.args
359    if (seen.has(current.ppid)) return null
360    seen.add(current.ppid)
361  }
362  return null
363}
364
365/**
366 * Which of Claude's recent Bash commands that shell runs. The tool passes the command
367 * single-quoted, a quote inside written as '"'"', or, with nothing to quote, as a bare word
368 * after `eval`; the whole word must match, so `ls` does not stand in for `./tools/setup.sh`,
369 * and the longest match wins, so `apt update` does not stand in for `sudo sh -c 'apt update'`.
370 */
371export function matchBashCommand(shellArgs: string, recent: readonly string[]): string | null {
372  const unquoted = `${shellArgs.replaceAll(`'"'"'`, `'`)} `
373  let best: string | null = null
374  for (const command of recent) {
375    const isRun = unquoted.includes(`'${command}'`) || unquoted.includes(`eval ${command} `)
376    if (isRun && command.length > (best?.length ?? 0)) best = command
377  }
378  return best
379}
380
381/** The Claude Code pid a session dir is named after (`<session id>-<pid>`). */
382export function claudePidOf(sessionDir: string): number | null {
383  const pid = /-(\d+)$/.exec(sessionDir)?.[1]
384  return pid === undefined ? null : Number(pid)
385}
386
387/** Rows `text` takes when wrapped at `width` characters (wide characters count as one). */
388export function wrappedLines(text: string, width: number): number {
389  return text.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0)
390}
391
392/** `text` cut to fit `maxRows` rows at `width`, an ellipsis marking the cut. */
393export function clipLines(text: string, maxRows: number, width: number): string {
394  const kept: string[] = []
395  let rows = 0
396  for (const line of text.split('\n')) {
397    const need = wrappedLines(line, width)
398    if (rows + need <= maxRows) {
399      kept.push(line)
400      rows += need
401      continue
402    }
403    const room = maxRows - rows
404    if (room > 0) {
405      kept.push(`${line.slice(0, room * width - 1)}…`)
406    } else {
407      // The last kept line is full: put the ellipsis in place of its last character.
408      const last = kept.pop() ?? ''
409      kept.push(last.length > 0 && last.length % width === 0 ? `${last.slice(0, -1)}…` : `${last}…`)
410    }
411    return kept.join('\n')
412  }
413  return kept.join('\n')
414}
415