Most mods written on Mac or Linux break on Windows because they use $.process to launch programs Windows doesn't have (tail, date, open, osascript, afplay, xdg-open and so on). $.process.run doesn't go through a shell, so cmd built-ins like start or dir can't be launched either. The other cause is sound: on Windows, $.audio.play plays nothing and still reports success.
Versions checked: Claude Code 2.1.284 to 2.1.288 (October 2026, Windows 11).
How common is this?
The modscode review read 1,383 public mods on GitHub and found 167 that use $.process to launch a program Windows doesn't have (as of 2026-10-06). That's roughly 1 in 8. Many still had Mac tool names left in them as-is.
$.process.run doesn't use a shell
The official API page says $.process.run takes an array of arguments and does not use a shell.
const r = await $.process.run(['git', 'status'])
// r is { exitCode, stdout, stderr }
Because there's no shell, this happens:
-
['tail', '-n', '20', 'log.txt']: Windows has notail, so it can't start and the call rejects -
['start', 'report.pdf']:startis a cmd built-in, not an executable, so it can't start -
['ls'],['cat', ...],['date']: same, they don't exist (dateis a cmd built-in and means something different anyway) - A single string like
'git status': pass an array instead
$.process.run rejects when the program can't start and when it times out (30 seconds by default). A non-zero exit code still resolves. If you don't wrap it in try/catch, the throw takes the whole hook down with it (part 5).
Programs Windows doesn't have, and what to use instead
The best fix is to not launch an external program at all and use the mods API instead. With the API you don't have to care about the OS.
| Common on Mac/Linux | Used for | Replacement inside a mod |
|---|---|---|
date |
Current time |
await $.clock.now() or Date.now()
|
tail, cat, head
|
Reading a file | Read with $.fs.read(path) and slice in JS |
ls, find
|
Listing a folder |
$.fs.list(path) (one level only, not recursive) |
test -f |
Checking existence | $.fs.exists(path) |
curl |
Network | $.http.fetch(url, init) |
osascript -e 'display notification', notify-send
|
Notifications | $.ui.toast(text) |
afplay, say
|
Sound, speech | See the "Sound" section below |
open, xdg-open
|
Open with the default app | On Windows only, PowerShell's Start-Process (below) |
pbcopy |
Clipboard |
$.ui.copy (listed in the official API list; check the behavior in the type definitions) |
sh -c '...', bash -c '...'
|
One-line shell commands | Launch directly with an array, or replace with the API |
Programs that ship an executable with the same name on Windows, like git or gh, work as-is, as long as they're on the PATH.
One more thing to watch: commands installed as .cmd files on Windows, like npm and npx. Whether a .cmd file can be launched directly without a shell isn't documented on the official pages, so this is not verified. If it doesn't work, launch it explicitly, e.g. ['cmd.exe', '/c', 'npm', ...].
Detecting the OS
There's no process inside a hook, so you can't use process.platform. I tell them apart by the shape of $.plugin.root (the plugin folder).
function isWindows($) {
const root = String($.plugin.root ?? '')
return /^[A-Za-z]:[\\/]/.test(root) // starts with a drive letter
}
Opening a file with the default app
Instead of Mac's open report.pdf, launch PowerShell's Start-Process on Windows. Don't embed the path in the command string; pass it through an environment variable. A single ' or space in the path breaks it, and it also opens the door to injection.
async function openFile($, path) {
if (isWindows($)) {
await $.process.run(
['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Start-Process -FilePath $env:MOD_FILE'],
{ env: { MOD_FILE: path }, timeoutMs: 15_000 },
)
return
}
await $.process.run(['open', path])
}
Start the open from a clock, not inside the button-press event. Processes started inside an event were sometimes killed when the event ended (part 8).
No sound: $.audio.play succeeds silently on Windows
await $.audio.play({ asset: 'sounds/success.wav' }) // Windows: plays nothing
On Windows this plays nothing, throws nothing, and never reaches .catch. The Code tab in the desktop app runs the same executable, so it was silent there too.
In this version, the core plays sound only through globalThis.Audio or macOS afplay. On Windows it writes "no audio player on windows; not played" to the debug log and resolves as a success. From your code it never looks like a failure.
Fix: launch PowerShell's SoundPlayer
On Windows only, use $.process.run to start PowerShell and play the WAV with System.Media.SoundPlayer.
const PS_PLAY = '(New-Object System.Media.SoundPlayer $env:MOD_WAV).PlaySync()'
let soundBusy = false
async function playSound($, clip) {
const root = String($.plugin.root ?? '')
if (!/^[A-Za-z]:[\\/]/.test(root)) return $.audio.play(clip)
if (soundBusy) return // drop sounds that arrive while one is playing
soundBusy = true
try {
const wav = `${root.replace(/[\\/]+$/, '')}\\${clip.asset.replace(/\//g, '\\')}`
await $.process.run(
['powershell.exe', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', PS_PLAY],
{ env: { MOD_WAV: wav }, timeoutMs: 15_000 },
)
} catch {
// don't stop the mod even if the sound fails
} finally {
soundBusy = false
}
}
Five things to watch
- Pass the path through an environment variable (same reason as above)
- Use PCM WAV. SoundPlayer can't play MP3
- Startup takes about 1 second. Drop sounds that arrive while one is playing so processes don't pile up
-
You can't change the volume (
gainhas no effect). Lower it in the WAV itself -
Don't start it inside an event (button or command). Play it from a
$.clock.everyclock created insession.startand it plays to the end
To narrow down "no sound" problems, it helps to keep one command that plays a sound and reports the result as text. In practice, almost every "no sound" case was either the PC being muted or this path difference. Once the core supports sound on Windows, this workaround becomes unnecessary. When the version goes up, try $.audio.play first.
The weight of launching PowerShell
So far the Windows workarounds have used PowerShell. But PowerShell is a shell that can do anything. When a mod launches a shell, a reader has to check the actual command string to know whether it's safe.
The modscode review also has "doesn't use a shell" as a check (the program name is written in the code and isn't a shell). The sound workaround above trips that check.
For a mod you publish, think in this order:
- If the API can do it, use the API (time, files, notifications, network)
- Launch an external program only when it can't, keep the command string fixed, and pass changing values through environment variables
- For "nice to have" features like sound, not playing it on Windows is also an option
Small Windows differences
-
Path separators: relative paths passed to
$.fsare resolved from the session's working folder. When you build paths yourself, accept both\and/ -
CLAUDE_CODE_PLUGIN_DIRS: when listing multiple folders, Windows separates them with;(Mac and Linux use:) - WSL: in the desktop app's WSL sessions, plugins themselves aren't available, so mods don't run (part 1)
Summary
| Symptom | Cause | Fix |
|---|---|---|
| A feature silently stops |
tail, date, open, osascript don't exist on Windows |
Replace with the mods API |
start can't open files |
$.process.run doesn't go through a shell |
Call powershell.exe Start-Process explicitly |
| No sound (no exception) |
$.audio.play succeeds silently on Windows |
PowerShell SoundPlayer |
| Opened apps or sounds get cut off | Processes are killed when the event ends | Start them from a clock |
To find out whether a mod works on Windows before installing it, search its code for $.process and look at the program names it launches. The 49 mods in the modscode reviewed gallery are only those whose code was checked to not launch programs missing on Windows.
This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked "not verified" could not be confirmed there.
Top comments (0)