Posts in category “Tips”

gitbash test fixtures: anchor TMPDIR at the real Windows temp root

git -C /tmp/tmp.XXX/repo fails on gitbash with cannot change to C:\Users\<u>\AppData\Local\Temp\2\tmp\tmp.XXX\repo — note the doubled tmp segment. git's Cygwin path resolver treats /tmp/foo as <Temp2>/tmp/foo, so a path the shell resolves fine ends up looking for a directory the disk doesn't have. Anything that pipes mktemp output into git -C hits it; mine broke ~20 of 60+ tests in one go.

The fix is to set TMPDIR to the real Windows temp root before any mktemp -d call. mktemp then returns C:\Users\<u>\AppData\Local\Temp\2\tmp.XXX (no leading /tmp/) and git's resolver is happy. cygpath -w /tmp gives the right value:

if [[ -n "${MSYSTEM:-}" ]] && command -v cygpath >/dev/null 2>&1; then
    export TMPDIR="$(cygpath -w /tmp)"
fi

A few related landmines I tripped over while getting to a green suite — bundle them since you will hit them too:

ln -sfn and readlink round-trip through Cygwin form. Symlink the Windows form C:\Users\...\tmp.X\auth.json and readlink returns /tmp/tmp.X/auth.json. A test that does assert_eq "${primary_home}/auth.json" "$(readlink …)" fails on gitbash even when both point at the same file. A cygpath -u on each side makes them comparable; I added an assert_path_eq helper for this. Don't use cd && pwd -P as the canonicalisation — it fails for target paths that don't yet exist as real directories (the symlink is dangling at assert time).

Windows jq emits CRLF. A pipeline ending in paste -sd, keeps the \r in each token, so expected "3,5,10" got "3\r,5\r,10" is a real failure mode. tr -d '\r' after the jq call fixes it; doing it inside the test's jq wrapper covers most cases.

Windows has no POSIX file mode bits. chmod +x is a no-op, chmod 700 is a no-op, and [[ -x ${path} ]] is always false for a no-extension file. The launch scripts the runtime writes are invoked with bash <path>, so missing exec bit is harmless in production — skip those assertions on MSYSTEM.

bash -x leaks inherited env to the transcript. If the parent shell sources ~/.bashrc.secret (mine does), set -x spews every export OAUTH_TOKEN=… into the output. Diagnostic prints are fine, but never set -x the real test — and redact the log if you did.

These four are the high-leverage fixes; everything else (tmux e2e, setsid/python missing, MINGW64 prompt prefix in pane titles) is one-test-at-a-case and worth a separate pass.

tg-relay 能否驱动非 tmux 的 Claude Code session?

tg-relay 的 inbound 路由依赖 tmux pane ID(%NN)——收到 Telegram 消息后,它调 tmux send-keys/tmp/tg-*.md 注入对应 pane。mux.driver local 跑的是前台进程,没有 tmux pane,所以 relay 找不到投递目标,直接失败。

Outbound 没问题:notify_shuke 不依赖 tmux,local driver 下照常发 TG,reply index 里用 MUX_DRIVER_SLUG 代替 %NN 记录身份。问题只在 inbound。

可行路径:named FIFO + Stop hook exit 2

每个 local session 启动时用 slug 创建一个 named FIFO:

mkfifo /tmp/mux-tg-${MUX_DRIVER_SLUG}.fifo

tg-relay 看到 reply index 里的 pane ID 是 slug 而非 %NN,就写这个 FIFO 而非调 tmux:

echo "$message" > /tmp/mux-tg-${slug}.fifo

Claude Code 的 Stop hook 在每个 turn 结束时检查这条 FIFO:

# Stop hook
fifo="/tmp/mux-tg-${MUX_DRIVER_SLUG}.fifo"
if read -t 0.1 msg < "$fifo" 2>/dev/null; then
    echo "$msg"
    exit 2   # 拦住 stop,把消息作为 additionalContext 注入
fi
exit 0

exit 2 的语义:Claude 不停止,stdout 作为 additionalContext(system feedback)注入同一个 turn,Claude 继续处理。技术上不是新的 user message,是 same-turn 的 system context,但效果上 Claude 会读到并响应 TG 消息。

局限

  • hook 只在 turn 边界触发。FIFO 里的消息要等当前 turn 结束才被拉取。如果turn结束时FIFO里是空的,那这个turn就正常结束了。没有机会再接到后续FIFO的内容。这是一个致命缺陷。让整个方案变得不再可行。
  • additionalContext ≠ user message:conversation history 里这不是一条用户消息,边角行为可能和正常 TG 路由有差异。
  • FIFO 阻塞:写端无读端时 echo > fifo 会阻塞,relay 需要用 O_NONBLOCK 或超时保护。

不完美,但架构上可行,不需要改动 Claude Code 本身。

`claude install` says success on Windows but `--version` is still old

You run claude install, it prints "successfully installed! Version: 2.1.185", but claude --version keeps reporting the old 2.1.183. Reinstalling doesn't help.

The cause: on Windows you cannot overwrite a running .exe. The native installer downloads the new build into ~/.local/share/claude/versions/<ver> fine, but the final step — copying it onto the launcher at ~/.local/bin/claude.exe — fails silently because a live claude.exe process holds that file locked. The installer reports success on the download, not on the swap.

Confirm it's this by comparing checksums of the launcher against the version store:

sha256sum ~/.local/bin/claude.exe ~/.local/share/claude/versions/2.1.185
ls -la ~/.local/share/claude/versions/   # newest version is there, but bin/claude.exe is stale
tasklist //FI "IMAGENAME eq claude.exe"  # the processes holding the lock

If the launcher hash matches an older version in the store, the swap never happened.

The clean fix is to exit every claude session and re-run claude install. But if you can't (e.g. you're driving from inside a claude session), use the fact that Windows lets you rename a locked file even though it won't let you overwrite it — the running process keeps its open handle, and a fresh file lands in the path:

cd ~/.local/bin
mv claude.exe claude.exe.old
cp ~/.local/share/claude/versions/2.1.185 claude.exe
chmod +x claude.exe
claude --version   # 2.1.185

New shells immediately pick up the new binary; running sessions keep using the old one until restarted. Delete claude.exe.old once everything has been restarted.

Note that Git Bash's bare claude (no extension) is just shell resolution of claude.exe — there's only one physical file to replace, not two. This whole trap is more likely if you have autoUpdates: false in ~/.claude.json and update manually, since background auto-update would normally retry on the next launch when nothing is locked.

xset s off vs xset -dpms: Do you need both?

On Linux (especially XFCE, GNOME, or other X11 desktops), if you want to keep your monitors from auto-sleeping while still being able to manually turn them off with a hotkey, you'll see these two commands recommended:

xset s off
xset -dpms

Do you need both? Usually not—but it doesn't hurt. Here's the difference:

xset -dpms controls the monitor power management (hardware sleep). This is the main one you want—it prevents the monitor from entering standby/suspend/off automatically.

xset s off controls the X11 screen saver (software blanking). In modern desktops, the screen saver usually just triggers DPMS anyway, so this often feels redundant. But it's a good safety layer to prevent "blank screen but not actually sleeping" behavior on some setups.

The practical setup

To never auto-sleep, but still allow manual off/on:

xset s off        # Disable screen saver blanking
xset -dpms        # Disable DPMS power saving

Then bind this to a hotkey to turn monitors off:

xset dpms force off

Moving the mouse or pressing any key wakes them back up, and they stay awake afterward (because DPMS stays disabled).

XFCE note

Don't forget to also disable display power management in: Settings → Power Manager → Display

Otherwise XFCE might re-enable DPMS behind your back.

I just found a might better way: directly use xfce4-screensaver-command --lock for hot key Alt+Ctrl+L. Thus you don't need to worry about XFCE re-eableing.

Replacing tmux send-keys for agent coordination? stdin FIFO is the right answer, not an alternative

tmux send-keys became the backbone of multi-agent relay systems for one reason: it could inject input into a running AI agent process at any moment, simulating a human interrupt. It worked. But it was always a hack — it targets a terminal pane, not a process, and carries all of tmux's fragility on Windows Git Bash with it.

The cleaner architecture separates the two things tmux was doing simultaneously:

1. Process lifecycle: supervisor loop

Instead of an external process injecting /next-run <path> into a live agent's stdin, the agent writes its own handoff file before exiting:

# supervisor wrapper
handoff="/tmp/next-run-${session_id}.md"
printf 'poll\n' > "$handoff"
while [[ -f "$handoff" ]]; do
    claude-code "/next-run $handoff"
done

The agent finishes a cycle, writes the next handoff, and exits cleanly. The supervisor reads the file and restarts. No injection, no timing race, no tmux session required. The agent writes its last will before dying — context stays clean across restarts because each cycle starts fresh.

2. Async wake / interrupt: stdin FIFO + registration file

For the "prod is on fire, wake up the polling agent" case, give each agent a named pipe as its stdin:

fifo="/tmp/mux-agent-${slug}-${backend}-${mode}.fifo"
mkfifo "$fifo"
claude-code "/next-run $handoff" < "$fifo" &
printf '%s\t%s\n' "$$" "$fifo" > "/tmp/mux-agent-${slug}-${backend}-${mode}.reg"

Any process that wants to wake the agent writes to the FIFO — same semantics as send-keys, but at the OS level, no tmux required.

3. Discovery: the registration file is the registry

mux agents no longer needs to walk tmux list-panes -a. It scans /tmp/mux-agent-*.reg, checks kill -0 $pid for each entry, and lists the live ones. The FIFO path in the registration file is both the agent's address and its communication channel — one mechanism solves discovery and messaging.

What this unlocks

With display decoupled from communication, agents can run entirely in the background. Output goes to a log file; mux log <agent> tails it when you want to watch. Closing a terminal window no longer kills the agent. On locked-down Windows hosts without WSL2, the whole stack runs in Git Bash with no tmux dependency at all.

The tmux send-keys approach worked because it exploited the one interface AI coding agents expose: their terminal stdin. The FIFO approach exploits the same interface at the OS level — it's not a workaround, it's the same idea done properly.

The one thing tmux still does better is real-time pane-peeking across agents in team mode. For multi-agent workflows where one agent needs to interrupt another mid-execution, stdin FIFO is the right replacement. For single-agent lifecycle management, the supervisor loop makes the interrupt unnecessary in the first place.