From 25790e855295b717cfb9a0262883fa7e86ad304f Mon Sep 17 00:00:00 2001 From: narghev Date: Fri, 8 May 2026 00:20:38 +0400 Subject: [PATCH 1/3] wip remove askdiff-stop, askdiff invokation now checks for existing askdiffs, kills them and recalculates the diff, askdiff temp files have session siffixes --- .claude/skills/askdiff-dev/SKILL.md | 109 ++++++++++++++++++++------- .claude/skills/askdiff-stop/SKILL.md | 72 ------------------ .claude/skills/askdiff/SKILL.md | 96 +++++++++++++++++------ README.md | 30 +++++--- 4 files changed, 175 insertions(+), 132 deletions(-) delete mode 100644 .claude/skills/askdiff-stop/SKILL.md diff --git a/.claude/skills/askdiff-dev/SKILL.md b/.claude/skills/askdiff-dev/SKILL.md index 3a7d21e..617cc17 100644 --- a/.claude/skills/askdiff-dev/SKILL.md +++ b/.claude/skills/askdiff-dev/SKILL.md @@ -93,14 +93,31 @@ each ref the user named directly. If any fails, stop and tell the user which ref didn't resolve — do not launch the server. (Refs returned by the search ladder are already validated by virtue of `git log` finding them.) -## Step 2 — write the diff to a temp file +## Step 2 — write the diff to a session-stable file + +First resolve the parent Claude Code session and project cwd. All `/tmp` +paths the skill writes (diff file, server log, dev-only UI log/pid file) +key off the session UUID so concurrent `/askdiff` runs from different +sessions don't collide: ```bash -diff_file=$(mktemp /tmp/askdiff-diff.XXXXXX) +session_file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions/$PPID.json" +session_id="" +project_cwd="$PWD" +if [ -f "$session_file" ]; then + session_id=$(sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p' "$session_file") + manifest_cwd=$(sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p' "$session_file") + [ -n "$manifest_cwd" ] && project_cwd="$manifest_cwd" +fi +suffix="${session_id:-pid-$$}" +diff_file="/tmp/askdiff-diff.$suffix" ``` -(macOS `mktemp` only substitutes trailing X's, so the template can't have -a `.diff` suffix. The server doesn't care about the extension.) +No random component on the diff file — re-invoking `/askdiff` from the +same session overwrites in place, which is exactly what a refresh would +do. Different sessions get different suffixes and don't collide. (If +launched outside a CC session, `session_id` is empty and the suffix +falls back to `pid-` so we still avoid collisions.) **Working tree (no description).** Untracked files don't appear in `git diff HEAD`, so we union them in via `--no-index`: @@ -143,24 +160,44 @@ the values from Step 2/3. ``` set +e -# Filled in by Step 2/3. +# Filled in by Step 2/3 (session_id, project_cwd, suffix come from Step 2's +# preamble — keep that block above this one in your final invocation). EXTRA_DIFF_FILE="" EXTRA_DIFF_LABEL="" -# 1. Free port for the WS server (default 7837, bump until free). -port=7837 -while lsof -iTCP:$port -sTCP:LISTEN -t >/dev/null 2>&1; do - port=$((port + 1)) -done +log_file="/tmp/askdiff.$suffix.log" +ui_log="/tmp/askdiff-ui.$suffix.log" +ui_pid_file="/tmp/askdiff-ui.$suffix.pid" +pid_file="/tmp/askdiff.$suffix.pid" + +# 1. If a server for this session is already running, kill it and remember +# its port. Reusing the port matters here especially: Vite's /ws proxy +# (ASKDIFF_DEV_WS_TARGET) is locked to whatever port we passed when +# Vite first started. Reusing keeps the browser tab alive — its WS +# will auto-reconnect (see lib/ws.ts) and load the new diff. +saved_port="" +if [ -f "$pid_file" ]; then + read -r old_pid saved_port < "$pid_file" 2>/dev/null + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" 2>/dev/null + if [ -n "$saved_port" ]; then + for _ in $(seq 1 20); do + lsof -iTCP:"$saved_port" -sTCP:LISTEN -t >/dev/null 2>&1 || break + sleep 0.1 + done + fi + fi + rm -f "$pid_file" +fi -# 2. Resolve parent Claude Code session + cwd. -session_file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions/$PPID.json" -session_id="" -project_cwd="$PWD" -if [ -f "$session_file" ]; then - session_id=$(sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p' "$session_file") - manifest_cwd=$(sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p' "$session_file") - [ -n "$manifest_cwd" ] && project_cwd="$manifest_cwd" +# 2. Pick a port: reuse the saved one if present, else pick from 7837 up. +if [ -n "$saved_port" ]; then + port="$saved_port" +else + port=7837 + while lsof -iTCP:$port -sTCP:LISTEN -t >/dev/null 2>&1; do + port=$((port + 1)) + done fi # 3. Start the WS server (in-repo via tsx). @@ -170,15 +207,15 @@ cd "$project_cwd" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ - nohup pnpm --filter @askdiff/server exec tsx src/main.ts > /tmp/askdiff.log 2>&1 & + nohup pnpm --filter @askdiff/server exec tsx src/main.ts > "$log_file" 2>&1 & +new_pid=$! disown sleep 1.5 -head -5 /tmp/askdiff.log +echo "$new_pid $port" > "$pid_file" +head -5 "$log_file" -# 4. Start Vite only if our previous one isn't still alive. +# 4. Start Vite only if our previous one isn't still alive (per session). # Pass ASKDIFF_DEV_WS_TARGET so Vite's proxy points at the chosen port. -ui_log=/tmp/askdiff-ui.log -ui_pid_file=/tmp/askdiff-ui.pid ui_running=false if [ -f "$ui_pid_file" ]; then prev_pid=$(cat "$ui_pid_file" 2>/dev/null) @@ -205,22 +242,38 @@ vite_port=$(sed -E -n 's|.*Local:[^0-9]*([0-9]+)/?.*|\1|p' "$ui_log" | head -1) ui_url="http://localhost:${vite_port}/" -(open "$ui_url" >/dev/null 2>&1 || xdg-open "$ui_url" >/dev/null 2>&1) & +# Only auto-open the browser on the *first* launch (no saved_port). On +# refresh-style re-invocations, the user's tab is still open and its WS +# will auto-reconnect; opening another tab would be annoying. +if [ -z "$saved_port" ]; then + (open "$ui_url" >/dev/null 2>&1 || xdg-open "$ui_url" >/dev/null 2>&1) & +fi echo "" +if [ -n "$saved_port" ]; then + echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." +fi echo "UI: $ui_url" +echo "WS log: $log_file" +echo "UI log: $ui_log" +echo "WS PID: $new_pid (saved to $pid_file)" ``` Then tell the user: - the WS server port (visible in the `listening on ws://...` line) - the resolved Claude session ID (from the `claude session:` line) - the diff label (always set) -- the WS log file: `/tmp/askdiff.log` -- the Vite log file: `/tmp/askdiff-ui.log` -- the UI URL (last echoed line) — already opened in their default browser +- the WS log file (printed as the `WS log:` line — `/tmp/askdiff..log`) +- the Vite log file (printed as the `UI log:` line — `/tmp/askdiff-ui..log`) +- the UI URL (last echoed `UI:` line) — already opened in their default browser If the `claude session:` line says `(none ...)`, the parent CC manifest was not found at `$session_file`. That usually means the server was launched from outside a Claude Code session. -`/askdiff-stop` cleans up both processes. +The WS server idle-shuts after 5 min with no connected clients (see +`ASKDIFF_IDLE_SHUTDOWN_MS`); re-invoking `/askdiff-dev` always kills the +previous WS server for this session before starting a new one. Vite +intentionally stays running across re-invocations (HMR is the whole +point) — kill it via Activity Monitor or `pkill -f 'ui-browser.*vite'` +on the rare occasion you want it gone. diff --git a/.claude/skills/askdiff-stop/SKILL.md b/.claude/skills/askdiff-stop/SKILL.md deleted file mode 100644 index c2fcec7..0000000 --- a/.claude/skills/askdiff-stop/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: askdiff-stop -description: Stop the askdiff WebSocket server and Vite dev server. -user-invocable: true -allowed-tools: Bash ---- - -Stop everything `/askdiff` and `/askdiff-dev` started: the WebSocket -server, the Vite dev server (when `/askdiff-dev` was used), and any -pnpm/esbuild helpers spawned along the way. - -Run this as a single Bash command: - -``` -set +e - -found_any=false - -# 1. Vite tracked by the pid file `/askdiff-dev` writes (graceful kill first). -ui_pid_file=/tmp/askdiff-ui.pid -if [ -f "$ui_pid_file" ]; then - prev_pid=$(cat "$ui_pid_file" 2>/dev/null) - if [ -n "$prev_pid" ] && kill -0 "$prev_pid" 2>/dev/null; then - kill "$prev_pid" 2>/dev/null - found_any=true - echo "killed Vite dev server (pid $prev_pid)" - fi - rm -f "$ui_pid_file" -fi - -# 2. Anything whose command line mentions askdiff — -# pnpm filters (`@askdiff/server`, `@askdiff/ui-browser`), the tsx-run -# server entry (which can appear as just `src/main.ts` due to pnpm -# --filter cwd), the Vite binary inside the ui-browser package, AND -# the published `askdiff` CLI when launched via npx (under -# `node_modules/askdiff/` or `.bin/askdiff`). -patterns='@askdiff/(server|ui-browser)|src/(main|index)\.ts|ui-browser/[^ ]*vite|node_modules/askdiff/|/\.bin/askdiff' -pids=$(pgrep -f "$patterns" 2>/dev/null | sort -u) - -if [ -n "$pids" ]; then - echo "killing: $(echo $pids | tr '\n' ' ')" - kill $pids 2>/dev/null - found_any=true - sleep 0.7 - # Force-kill any survivors. - for pid in $pids; do - if kill -0 "$pid" 2>/dev/null; then - kill -9 "$pid" 2>/dev/null - echo "force-killed pid $pid" - fi - done -fi - -# 3. Final sweep — anything still listening on a typical askdiff WS port. -for port in $(seq 7837 7847); do - lsof_pid=$(lsof -iTCP:$port -sTCP:LISTEN -t 2>/dev/null) - if [ -n "$lsof_pid" ]; then - # Only kill if it looks like ours — match against our patterns again. - if ps -p "$lsof_pid" -o command= 2>/dev/null | grep -qE "$patterns"; then - kill -9 "$lsof_pid" 2>/dev/null - echo "killed leftover on :$port (pid $lsof_pid)" - found_any=true - fi - fi -done - -if ! $found_any; then - echo "no askdiff processes running" -fi -``` - -Then tell the user what was killed (or that nothing was running). diff --git a/.claude/skills/askdiff/SKILL.md b/.claude/skills/askdiff/SKILL.md index 36cb768..ca6b355 100644 --- a/.claude/skills/askdiff/SKILL.md +++ b/.claude/skills/askdiff/SKILL.md @@ -88,14 +88,31 @@ each ref the user named directly. If any fails, stop and tell the user which ref didn't resolve — do not launch the server. (Refs returned by the search ladder are already validated by virtue of `git log` finding them.) -## Step 2 — write the diff to a temp file +## Step 2 — write the diff to a session-stable file + +First resolve the parent Claude Code session and project cwd. All `/tmp` +paths the skill writes (diff file, server log, dev-only UI log/pid file) +key off the session UUID so concurrent `/askdiff` runs from different +sessions don't collide: ```bash -diff_file=$(mktemp /tmp/askdiff-diff.XXXXXX) +session_file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions/$PPID.json" +session_id="" +project_cwd="$PWD" +if [ -f "$session_file" ]; then + session_id=$(sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p' "$session_file") + manifest_cwd=$(sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p' "$session_file") + [ -n "$manifest_cwd" ] && project_cwd="$manifest_cwd" +fi +suffix="${session_id:-pid-$$}" +diff_file="/tmp/askdiff-diff.$suffix" ``` -(macOS `mktemp` only substitutes trailing X's, so the template can't have -a `.diff` suffix. The server doesn't care about the extension.) +No random component on the diff file — re-invoking `/askdiff` from the +same session overwrites in place, which is exactly what a refresh would +do. Different sessions get different suffixes and don't collide. (If +launched outside a CC session, `session_id` is empty and the suffix +falls back to `pid-` so we still avoid collisions.) **Working tree (no description).** Untracked files don't appear in `git diff HEAD`, so we union them in via `--no-index`: @@ -145,18 +162,31 @@ set +e # this repo always pulls the newest published version). ASKDIFF_VERSION="latest" -# Filled in by Step 2/3. +# Filled in by Step 2/3 (session_id, project_cwd, suffix come from Step 2's +# preamble — keep that block above this one in your final invocation). EXTRA_DIFF_FILE="" EXTRA_DIFF_LABEL="" -# 1. Resolve parent Claude Code session + cwd. -session_file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions/$PPID.json" -session_id="" -project_cwd="$PWD" -if [ -f "$session_file" ]; then - session_id=$(sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p' "$session_file") - manifest_cwd=$(sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p' "$session_file") - [ -n "$manifest_cwd" ] && project_cwd="$manifest_cwd" +log_file="/tmp/askdiff.$suffix.log" +pid_file="/tmp/askdiff.$suffix.pid" + +# 1. If a server for this session is already running, kill it and remember +# its port so the new server reuses it. Reusing the port keeps the +# open browser tab's URL valid across the restart — the WS will +# auto-reconnect (see lib/ws.ts) and load the freshly-written diff. +saved_port="" +if [ -f "$pid_file" ]; then + read -r old_pid saved_port < "$pid_file" 2>/dev/null + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" 2>/dev/null + if [ -n "$saved_port" ]; then + for _ in $(seq 1 20); do + lsof -iTCP:"$saved_port" -sTCP:LISTEN -t >/dev/null 2>&1 || break + sleep 0.1 + done + fi + fi + rm -f "$pid_file" fi # 2. Update check. Skipped if explicitly disabled, or if pinned to @@ -171,29 +201,49 @@ if [ -z "$ASKDIFF_SKIP_UPDATE_CHECK" ] && [ "$ASKDIFF_VERSION" != "latest" ]; th fi fi -# 3. Launch. +# 3. Launch. Pass --port if we have one to reuse; otherwise the CLI picks 7837+. +port_arg="" +[ -n "$saved_port" ] && port_arg="--port $saved_port" + cd "$project_cwd" \ && ASKDIFF_SESSION_ID="$session_id" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ - nohup npx -y askdiff@"$ASKDIFF_VERSION" --no-open > /tmp/askdiff.log 2>&1 & + nohup npx -y askdiff@"$ASKDIFF_VERSION" --no-open $port_arg > "$log_file" 2>&1 & +new_pid=$! disown # Wait for the listening line to land in the log. for _ in $(seq 1 60); do - grep -q "listening on" /tmp/askdiff.log 2>/dev/null && break + grep -q "listening on" "$log_file" 2>/dev/null && break sleep 0.25 done -url=$(sed -nE 's|.*listening on (http://localhost:[0-9]+).*|\1|p' /tmp/askdiff.log | head -1) -[ -z "$url" ] && url="http://localhost:7837" +# 4. Persist so the next /askdiff invocation in this session +# can find and replace this server (the file path is session-keyed in +# Step 2's preamble). +port=$(sed -nE 's|.*listening on http://localhost:([0-9]+).*|\1|p' "$log_file" | head -1) +[ -z "$port" ] && port=7837 +echo "$new_pid $port" > "$pid_file" -(open "$url" >/dev/null 2>&1 || xdg-open "$url" >/dev/null 2>&1) & +url="http://localhost:$port/" -head -10 /tmp/askdiff.log +# Only auto-open the browser on the *first* launch (no saved_port). On +# refresh-style re-invocations, the user's tab is still open and will +# reconnect automatically; opening another tab would be annoying. +if [ -z "$saved_port" ]; then + (open "$url" >/dev/null 2>&1 || xdg-open "$url" >/dev/null 2>&1) & +fi + +head -10 "$log_file" echo "" +if [ -n "$saved_port" ]; then + echo "Refreshed: same port, new diff. Browser tab will auto-reconnect." +fi echo "UI: $url" +echo "Log: $log_file" +echo "PID: $new_pid (saved to $pid_file)" ``` **If the output is the single line `UPDATE_AVAILABLE: pinned=X latest=Y`** @@ -215,8 +265,10 @@ launch already happened. Tell the user: - the WS server URL (the `listening on http://...` line) - the resolved Claude session ID (the `claude session:` line) - the diff label (always set) -- the log file: `/tmp/askdiff.log` -- the UI URL (last echoed line) — already opened in their default browser +- the log file: `/tmp/askdiff.$suffix.log` (printed as the last `Log:` line) +- the UI URL (last echoed `UI:` line) — opened on first launch; on a + refresh-style re-invocation (the `Refreshed:` line is present), the + user's existing tab will reconnect automatically If the `claude session:` line says `(none ...)`, the parent CC manifest was not found at `$session_file`. That usually means askdiff was diff --git a/README.md b/README.md index 7e4b128..81e7347 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,13 @@ subsequent `/askdiff` runs the upgraded CLI. `install-skill` writes one file: `~/.claude/skills/askdiff/SKILL.md`. That's the entire surface area in your CC config. -In this repo (for contributors) there are two more: +In this repo (for contributors) there is one more: - `/askdiff-dev` — local Vite dev server with HMR + tsx-run WS server. - Use when editing `packages/ui-browser`. -- `/askdiff-stop` — kill everything `/askdiff` and `/askdiff-dev` started. + Use when editing `packages/server` or `packages/ui-browser`. Re-invoking + `/askdiff-dev` (or `/askdiff`) from the same session kills the previous + server, reuses its port, and points at a freshly-written diff — that's + the refresh path. The WS server idle-shuts after 5 min with no clients. ## Architecture @@ -150,10 +152,16 @@ pnpm run build From a Claude Code session in this repo: ``` -/askdiff-dev # Vite + WS server with HMR -/askdiff-stop # tear it down +/askdiff-dev # first launch: Vite + WS server with HMR +/askdiff-dev # again: kills the WS server, restarts on same port with a fresh diff +/askdiff-dev last commit # description-driven: HEAD~1..HEAD ``` +The WS server idle-shuts after 5 min with no connected clients; Vite is +intentionally persistent (HMR is the whole point). Kill Vite via +Activity Monitor or `pkill -f 'ui-browser.*vite'` on the rare occasion +you want it gone. + To exercise the production-shaped binary locally: ```bash @@ -170,13 +178,15 @@ askdiff from outside a Claude Code session (no `$PPID.json` in else. Pass `--session ` explicitly to override. **"Port 7837 is already in use"** -Another askdiff is running, or something else grabbed the port. Run -`/askdiff-stop` (in-repo), or pass `--port 7838`. +Another askdiff (from a different session) is running, or something +else grabbed the port. Same-session re-invocations don't hit this — +they reuse their session's saved port. Pass `--port 7838` to force a +specific port, or wait 5 min for the idle WS server to self-terminate. **Browser opens, UI loads, but never connects** -The WS upgrade is failing. Check `/tmp/askdiff.log` — usually it's an -old version of the UI cached against a new server (run -`/askdiff-stop` and reload the browser tab) or a hung +The WS upgrade is failing. Check `/tmp/askdiff..log` (where +`` is your CC session UUID) — usually it's an old UI cached +against a new server (reload the browser tab) or a hung `claude --resume` subprocess (check `ps aux | grep claude`). **`/askdiff` doesn't appear in Claude Code's skill picker** From 434196ab0ea78c66ed58a01ba744288ded47b718 Mon Sep 17 00:00:00 2001 From: narghev Date: Fri, 8 May 2026 00:40:13 +0400 Subject: [PATCH 2/3] staleness banner, detect file change and show a banner after calculating the diff --- .claude/skills/askdiff-dev/SKILL.md | 7 ++++ .claude/skills/askdiff/SKILL.md | 7 ++++ packages/cli/src/index.ts | 11 +++++ packages/protocol/src/schemas.test.ts | 32 +++++++++++++++ packages/protocol/src/schemas.ts | 11 +++++ packages/server/src/index.ts | 16 ++++++++ packages/server/src/main.ts | 9 +++++ packages/server/src/util/staleness.ts | 40 +++++++++++++++++++ packages/ui-browser/src/App.tsx | 2 + .../ui-browser/src/components/StaleBanner.tsx | 28 +++++++++++++ packages/ui-browser/src/lib/store.ts | 30 ++++++++++++-- packages/ui-browser/src/lib/ws.ts | 2 +- 12 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/util/staleness.ts create mode 100644 packages/ui-browser/src/components/StaleBanner.tsx diff --git a/.claude/skills/askdiff-dev/SKILL.md b/.claude/skills/askdiff-dev/SKILL.md index 617cc17..d241788 100644 --- a/.claude/skills/askdiff-dev/SKILL.md +++ b/.claude/skills/askdiff-dev/SKILL.md @@ -146,6 +146,12 @@ user the requested diff is empty and don't launch. The working-tree path *can* legitimately be empty (clean tree); launch anyway and the UI will show "No changes." +**Mark the diff as volatile if you took the working-tree path.** Set +`volatile=1` if Step 2 used the working-tree block (the diff can drift as +the user keeps editing); set `volatile=0` for description-based diffs +(immutable git history). Step 4 forwards this to the server as +`ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. + ## Step 3 — pick a short label Use the "Suggested label" column above. For the working-tree case, use @@ -207,6 +213,7 @@ cd "$project_cwd" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ + ASKDIFF_DIFF_VOLATILE="${volatile:-0}" \ nohup pnpm --filter @askdiff/server exec tsx src/main.ts > "$log_file" 2>&1 & new_pid=$! disown diff --git a/.claude/skills/askdiff/SKILL.md b/.claude/skills/askdiff/SKILL.md index ca6b355..2fa8708 100644 --- a/.claude/skills/askdiff/SKILL.md +++ b/.claude/skills/askdiff/SKILL.md @@ -144,6 +144,12 @@ user the requested diff is empty and don't launch. (`/askdiff HEAD vs HEAD` is the canonical empty case.) The working-tree path *can* legitimately be empty (clean tree); launch anyway and the UI will show "No changes." +**Mark the diff as volatile if you took the working-tree path.** Set +`volatile=1` if Step 2 used the working-tree block (the diff can drift as +the user keeps editing); set `volatile=0` for description-based diffs +(immutable git history). Step 4 forwards this to the server as +`ASKDIFF_DIFF_VOLATILE`, which gates the per-file mtime staleness check. + ## Step 3 — pick a short label Use the "Suggested label" column above. For the working-tree case, use @@ -210,6 +216,7 @@ cd "$project_cwd" \ ASKDIFF_PROJECT_CWD="$project_cwd" \ ASKDIFF_DIFF_FILE="$EXTRA_DIFF_FILE" \ ASKDIFF_DIFF_LABEL="$EXTRA_DIFF_LABEL" \ + ASKDIFF_DIFF_VOLATILE="${volatile:-0}" \ nohup npx -y askdiff@"$ASKDIFF_VERSION" --no-open $port_arg > "$log_file" 2>&1 & new_pid=$! disown diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 35262af..236c407 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -80,6 +80,7 @@ async function runServer(opts: RunOptions): Promise { sessionId: resolved.session, diffFile: resolved.diffFile, ...(resolved.diffLabel !== undefined ? { diffLabel: resolved.diffLabel } : {}), + volatile: resolved.volatile, httpServer, onListening: (resolvedPort) => { const url = `http://localhost:${String(resolvedPort)}/`; @@ -91,6 +92,7 @@ async function runServer(opts: RunOptions): Promise { ); console.log(` diff file: ${resolved.diffFile}`); if (resolved.diffLabel) console.log(` diff label: ${resolved.diffLabel}`); + if (resolved.volatile) console.log(` diff kind: volatile (staleness checks enabled)`); console.log(` websocket: ws://localhost:${String(resolvedPort)}${WS_PATH}`); }, }); @@ -148,6 +150,7 @@ interface ResolvedOptions { cwd: string; diffFile: string; diffLabel?: string; + volatile: boolean; } async function resolveOptions(opts: RunOptions): Promise { @@ -174,6 +177,7 @@ async function resolveOptions(opts: RunOptions): Promise { ); } const diffLabel = process.env["ASKDIFF_DIFF_LABEL"]; + const volatile = isTruthy(process.env["ASKDIFF_DIFF_VOLATILE"]); return { port, @@ -183,9 +187,16 @@ async function resolveOptions(opts: RunOptions): Promise { cwd, diffFile, ...(diffLabel ? { diffLabel } : {}), + volatile, }; } +const isTruthy = (v: string | undefined): boolean => { + if (v === undefined) return false; + const lower = v.trim().toLowerCase(); + return lower === "1" || lower === "true" || lower === "yes"; +}; + interface ParentManifest { sessionId?: string; cwd?: string; diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index bf614c0..36a46b6 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -237,6 +237,38 @@ describe("DiffMessageSchema", () => { }); expect(result.success).toBe(false); }); + + it("accepts optional stale + staleFiles", () => { + const result = DiffMessageSchema.safeParse({ + type: "diff", + raw: "", + files: [], + label: "Working tree", + stale: true, + staleFiles: ["src/foo.ts", "src/bar.ts"], + }); + expect(result.success).toBe(true); + }); + + it("rejects a non-boolean stale", () => { + const result = DiffMessageSchema.safeParse({ + type: "diff", + raw: "", + files: [], + stale: "yes", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-string entries in staleFiles", () => { + const result = DiffMessageSchema.safeParse({ + type: "diff", + raw: "", + files: [], + staleFiles: ["src/foo.ts", 42], + }); + expect(result.success).toBe(false); + }); }); describe("ChunkMessageSchema", () => { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index a4553ee..3695a06 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -65,6 +65,17 @@ export const DiffMessageSchema = z.object({ // "main…feature/x", "Working tree"). The skill sets it via the // ASKDIFF_DIFF_LABEL env var; absent for legacy clients. label: z.string().optional(), + // Set true when the server detected that one or more files in the diff + // have been modified (or removed) since the diff was captured. Only + // populated for "volatile" diffs — i.e. working-tree diffs the skill + // marked with ASKDIFF_DIFF_VOLATILE=1. Description-based diffs + // (HEAD~1..HEAD, main…feature/x) never set this since their content + // doesn't depend on the working tree. + stale: z.boolean().optional(), + // Paths (relative to project cwd) of files whose mtime is newer than + // the diff file's mtime, or that no longer exist on disk. The UI uses + // this to mark individual files in the FileTree. + staleFiles: z.array(z.string()).optional(), }); export const ChunkMessageSchema = z.object({ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b340b44..fb5df50 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,7 @@ import type { Socket } from "node:net"; import { WebSocketServer, type WebSocket } from "ws"; import { ClaudeCliError, streamAnswer } from "./claude"; import { DiffError, getDiff } from "./util/diff"; +import { checkStaleness } from "./util/staleness"; import { PROTOCOL_VERSION, parseClientMessage, type AskMessage } from "@askdiff/protocol"; import { DEFAULT_HOST, DEFAULT_IDLE_SHUTDOWN_MS, PROJECT_NAME } from "./util/constants"; import { isValidSessionId, sessionExists } from "./util/session"; @@ -21,6 +22,10 @@ export interface ServerState { clients: Set; diffFile: string; diffLabel?: string; + // True when the diff is captured from the working tree (i.e. its + // contents can drift as the user edits files). Triggers the + // staleness check on every diff push. + volatile: boolean; } export interface StartServerOptions { @@ -32,6 +37,10 @@ export interface StartServerOptions { diffFile: string; // Short human description of the diff for the UI badge. diffLabel?: string; + // Whether the diff was captured from the working tree. Defaults to + // false (history-based diffs are immutable). When true, every diff + // push includes mtime-based staleness flags. + volatile?: boolean; // Standalone mode: pass `port` (and optionally `host`) and the server // creates its own HTTP listener. port?: number; @@ -52,11 +61,17 @@ export interface ServerHandle { async function sendDiff(ws: WebSocket, state: ServerState): Promise { try { const { raw, files } = await getDiff(state.diffFile); + const staleness = state.volatile + ? await checkStaleness(state.diffFile, state.cwd, files) + : { stale: false, staleFiles: [] }; send(ws, { type: "diff", raw, files, ...(state.diffLabel !== undefined ? { label: state.diffLabel } : {}), + ...(staleness.stale + ? { stale: true, staleFiles: staleness.staleFiles } + : {}), }); } catch (err) { const message = @@ -149,6 +164,7 @@ export async function startServer(opts: StartServerOptions): Promise { + if (v === undefined) return false; + const lower = v.trim().toLowerCase(); + return lower === "1" || lower === "true" || lower === "yes"; +}; + async function main(): Promise { const cwd = process.env["ASKDIFF_PROJECT_CWD"] ?? process.cwd(); const port = Number.parseInt(process.env["PORT"] ?? "0", 10) || DEFAULT_PORT; @@ -29,6 +35,7 @@ async function main(): Promise { process.exit(1); } const diffLabel = process.env["ASKDIFF_DIFF_LABEL"]; + const volatile = isTruthy(process.env["ASKDIFF_DIFF_VOLATILE"]); const initialSession = await resolveInitialSessionId(cwd); @@ -37,6 +44,7 @@ async function main(): Promise { sessionId: initialSession, diffFile, ...(diffLabel ? { diffLabel } : {}), + volatile, port, idleShutdownMs, onListening: (resolvedPort) => { @@ -50,6 +58,7 @@ async function main(): Promise { ); console.log(` diff file: ${diffFile}`); if (diffLabel) console.log(` diff label: ${diffLabel}`); + if (volatile) console.log(` diff kind: volatile (staleness checks enabled)`); if (idleShutdownMs > 0) { console.log( ` idle shutdown: ${String(Math.round(idleShutdownMs / 1000))}s after last client`, diff --git a/packages/server/src/util/staleness.ts b/packages/server/src/util/staleness.ts new file mode 100644 index 0000000..a7e8501 --- /dev/null +++ b/packages/server/src/util/staleness.ts @@ -0,0 +1,40 @@ +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { DiffFile } from "@askdiff/protocol"; + +export interface StalenessResult { + stale: boolean; + staleFiles: string[]; +} + +// Compare the diff file's mtime against the mtime of each file in the +// parsed diff (relative to `cwd`). A file counts as "stale" if its +// current mtime is newer than the diff file's mtime, or if it has been +// removed from disk. Only meaningful for working-tree diffs — call sites +// must gate on `state.volatile`. +export const checkStaleness = async ( + diffFile: string, + cwd: string, + files: DiffFile[], +): Promise => { + const diffStat = await stat(diffFile).catch(() => null); + if (diffStat === null) return { stale: false, staleFiles: [] }; + const diffMtime = diffStat.mtimeMs; + + const staleFiles: string[] = []; + await Promise.all( + files.map(async (file) => { + if (file.path.length === 0) return; + const filePath = join(cwd, file.path); + const fileStat = await stat(filePath).catch(() => null); + // Missing file ⇒ removed since the diff was taken ⇒ stale. + if (fileStat === null) { + staleFiles.push(file.path); + return; + } + if (fileStat.mtimeMs > diffMtime) staleFiles.push(file.path); + }), + ); + + return { stale: staleFiles.length > 0, staleFiles }; +}; diff --git a/packages/ui-browser/src/App.tsx b/packages/ui-browser/src/App.tsx index 716b435..e04f158 100644 --- a/packages/ui-browser/src/App.tsx +++ b/packages/ui-browser/src/App.tsx @@ -1,5 +1,6 @@ import { useWebSocket } from "./hooks/use-websocket"; import { TopBar } from "./components/TopBar"; +import { StaleBanner } from "./components/StaleBanner"; import { FileTree } from "./components/FileTree"; import { DiffPane } from "./components/DiffPane"; import { Toaster } from "./components/Toaster"; @@ -10,6 +11,7 @@ export const App = () => { return (
+
diff --git a/packages/ui-browser/src/components/StaleBanner.tsx b/packages/ui-browser/src/components/StaleBanner.tsx new file mode 100644 index 0000000..47e7080 --- /dev/null +++ b/packages/ui-browser/src/components/StaleBanner.tsx @@ -0,0 +1,28 @@ +import { AlertTriangle } from "lucide-react"; +import { useStore } from "@/lib/store"; + +export const StaleBanner = () => { + const stale = useStore((s) => s.diff?.stale); + const staleFiles = useStore((s) => s.diff?.staleFiles); + + if (!stale) return null; + + const count = staleFiles?.length ?? 0; + const fileWord = count === 1 ? "file has" : "files have"; + + return ( +
+
+ ); +}; diff --git a/packages/ui-browser/src/lib/store.ts b/packages/ui-browser/src/lib/store.ts index fbf4038..314c187 100644 --- a/packages/ui-browser/src/lib/store.ts +++ b/packages/ui-browser/src/lib/store.ts @@ -40,7 +40,17 @@ type Store = { sessionId: string | null; // diff - diff?: { raw: string; files: DiffFile[]; label?: string }; + diff?: { + raw: string; + files: DiffFile[]; + label?: string; + // Set when the server detected the diff has drifted from the + // working tree (only ever true for volatile/working-tree diffs). + stale?: boolean; + // Paths (relative to project cwd) that are newer than the diff + // file's mtime, or that have been removed from disk. + staleFiles?: string[]; + }; selectedFile?: string; // per-file UI state (path → flag) @@ -70,7 +80,13 @@ type Store = { setProtocol: (p: string) => void; setProject: (p: string) => void; setSessionId: (sid: string | null) => void; - setDiff: (raw: string, files: DiffFile[], label?: string) => void; + setDiff: ( + raw: string, + files: DiffFile[], + label?: string, + stale?: boolean, + staleFiles?: string[], + ) => void; toggleViewed: (path: string) => void; toggleCollapsed: (path: string) => void; toggleTreeNode: (path: string) => void; @@ -139,12 +155,18 @@ export const useStore = create((set, get) => ({ setProject: (p) => set({ project: p }), setSessionId: (sid) => set({ sessionId: sid }), - setDiff: (raw, files, label) => { + setDiff: (raw, files, label, stale, staleFiles) => { const s = get(); const prev = s.selectedFile; const stillExists = prev !== undefined && files.some((f) => f.path === prev); const next: Partial = { - diff: { raw, files, ...(label !== undefined ? { label } : {}) }, + diff: { + raw, + files, + ...(label !== undefined ? { label } : {}), + ...(stale !== undefined ? { stale } : {}), + ...(staleFiles !== undefined ? { staleFiles } : {}), + }, }; if (stillExists) { next.selectedFile = prev; diff --git a/packages/ui-browser/src/lib/ws.ts b/packages/ui-browser/src/lib/ws.ts index 6395357..53751ef 100644 --- a/packages/ui-browser/src/lib/ws.ts +++ b/packages/ui-browser/src/lib/ws.ts @@ -45,7 +45,7 @@ const dispatch = (msg: ServerMessage) => { s.setSessionId(msg.session_id); return; case "diff": - s.setDiff(msg.raw, msg.files, msg.label); + s.setDiff(msg.raw, msg.files, msg.label, msg.stale, msg.staleFiles); return; case "chunk": s.appendChunk(msg.id, msg.delta); From 2116e78b85f3365823697919ea9f03109821e8d5 Mon Sep 17 00:00:00 2001 From: narghev Date: Fri, 8 May 2026 00:47:19 +0400 Subject: [PATCH 3/3] add staleness.test --- packages/server/src/util/staleness.test.ts | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/server/src/util/staleness.test.ts diff --git a/packages/server/src/util/staleness.test.ts b/packages/server/src/util/staleness.test.ts new file mode 100644 index 0000000..712475d --- /dev/null +++ b/packages/server/src/util/staleness.test.ts @@ -0,0 +1,147 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import type { DiffFile } from "@askdiff/protocol"; +import { checkStaleness } from "./staleness"; + +// Each test gets its own scratch directory so the diff-file mtime and +// per-file mtimes can be set independently without bleed across cases. +const fakeDiffFiles = (...paths: string[]): DiffFile[] => + paths.map((p) => ({ path: p, hunks: [] })); + +const setMtime = async (path: string, mtime: Date) => { + await utimes(path, mtime, mtime); +}; + +describe("checkStaleness", () => { + let dir: string; + let diffFile: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "askdiff-staleness-test-")); + diffFile = join(dir, "diff"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns not stale for an empty files array", async () => { + writeFileSync(diffFile, ""); + const result = await checkStaleness(diffFile, dir, []); + expect(result).toEqual({ stale: false, staleFiles: [] }); + }); + + it("returns not stale when the diff file does not exist", async () => { + // Skipping the diff-file write entirely — checkStaleness should swallow + // the stat ENOENT and degrade to "no staleness signal". + const result = await checkStaleness( + join(dir, "missing.diff"), + dir, + fakeDiffFiles("foo.ts"), + ); + expect(result).toEqual({ stale: false, staleFiles: [] }); + }); + + it("flags a file whose mtime is newer than the diff file's mtime", async () => { + const past = new Date(Date.now() - 60_000); + const now = new Date(); + + writeFileSync(diffFile, ""); + await setMtime(diffFile, past); + + const filePath = join(dir, "newer.ts"); + writeFileSync(filePath, ""); + await setMtime(filePath, now); + + const result = await checkStaleness(diffFile, dir, fakeDiffFiles("newer.ts")); + expect(result).toEqual({ stale: true, staleFiles: ["newer.ts"] }); + }); + + it("does not flag a file whose mtime is older than the diff file's mtime", async () => { + const past = new Date(Date.now() - 60_000); + const now = new Date(); + + const filePath = join(dir, "older.ts"); + writeFileSync(filePath, ""); + await setMtime(filePath, past); + + writeFileSync(diffFile, ""); + await setMtime(diffFile, now); + + const result = await checkStaleness(diffFile, dir, fakeDiffFiles("older.ts")); + expect(result).toEqual({ stale: false, staleFiles: [] }); + }); + + it("flags a file that no longer exists on disk", async () => { + writeFileSync(diffFile, ""); + // "missing.ts" is in the diff but never created on disk — should be + // treated as stale (file deleted since the diff was taken). + const result = await checkStaleness(diffFile, dir, fakeDiffFiles("missing.ts")); + expect(result).toEqual({ stale: true, staleFiles: ["missing.ts"] }); + }); + + it("returns only the stale entries from a mixed set", async () => { + const past = new Date(Date.now() - 60_000); + const now = new Date(); + + writeFileSync(diffFile, ""); + await setMtime(diffFile, past); + + const oldFile = join(dir, "old.ts"); + writeFileSync(oldFile, ""); + await setMtime(oldFile, past); + + const newFile = join(dir, "new.ts"); + writeFileSync(newFile, ""); + await setMtime(newFile, now); + + // "ghost.ts" is missing → stale. + const result = await checkStaleness( + diffFile, + dir, + fakeDiffFiles("old.ts", "new.ts", "ghost.ts"), + ); + + expect(result.stale).toBe(true); + // Order isn't guaranteed (Promise.all + .push()), so compare as a set. + expect(result.staleFiles.sort()).toEqual(["ghost.ts", "new.ts"]); + expect(result.staleFiles).not.toContain("old.ts"); + }); + + it("skips entries with an empty path string", async () => { + writeFileSync(diffFile, ""); + // Defensive: parseUnifiedDiff can produce a `{ path: "", ... }` entry + // for prologue-only input (see diff.ts:60). checkStaleness should + // ignore those rather than stat the cwd itself. + const result = await checkStaleness(diffFile, dir, [{ path: "", hunks: [] }]); + expect(result).toEqual({ stale: false, staleFiles: [] }); + }); + + it("treats files in nested subdirectories the same as top-level", async () => { + const past = new Date(Date.now() - 60_000); + const now = new Date(); + + writeFileSync(diffFile, ""); + await setMtime(diffFile, past); + + // Build a nested path: /src/util/nested.ts. + const nestedDir = join(dir, "src", "util"); + mkdirSync(nestedDir, { recursive: true }); + const nested = join(nestedDir, "nested.ts"); + writeFileSync(nested, ""); + await setMtime(nested, now); + + const result = await checkStaleness( + diffFile, + dir, + fakeDiffFiles("src/util/nested.ts"), + ); + expect(result).toEqual({ + stale: true, + staleFiles: ["src/util/nested.ts"], + }); + }); +});