Skip to content

Commit 27f1688

Browse files
authored
Merge pull request #5 from magaransoft/feat/prune-and-error-ux
feat: prune subcommand, error-UX guidance, cs-roslyn scaffold
2 parents fb2b755 + ca25e73 commit 27f1688

12 files changed

Lines changed: 398 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented here.
44

55
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- `bin/{metals,py,ts,cs,vue,java}-direct prune` — new subcommand removes state dirs whose recorded process is dead AND whose port is unreachable. Eliminates accumulating "dead" entries in `status` output across worktree-add/worktree-remove cycles. `metals-direct status` now also reports `adopted` (running but launched externally, e.g. by IDE) distinct from `alive` (launched by this wrapper) and `dead`.
11+
- `bin/cs-roslyn-direct` (scaffold, NOT wired into install) — parallel C# wrapper targeting Microsoft Roslyn Language Server (the binary shipped with VS Code C# Dev Kit) instead of csharp-ls. Goal: ≥10× cold-start improvement on `documentSymbol`. Currently blocked by reverse-RPC gap in `bin/lsp-stdio-proxy.js` (Roslyn LS issues `workspace/configuration` server→client during `initialized` and SIGABRTs without a response). See `docs/per-language/csharp.md` § Roslyn LS scaffold for resumption path.
12+
13+
### Changed
14+
- `bin/{py,ts,cs,vue,java}-direct` — error message on HTTP 500 from underlying LSP now includes the failed method name AND retry hints (`<wrapper> tools` to verify method shape, `<wrapper> status` to verify daemon health). Previously a bare "lsp call failed" left callers with no recovery signal — observed in equinox session b8957617 audit (2026-05-06) where the orchestrator silently fell back to grep instead of retrying via the wrapper.
15+
716
## [1.3.0] — 2026-04-22
817

918
### Added

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ CLI → <tool>-direct (bash)
173173
from the underlying server), except `metals-direct` which exposes
174174
`metals-mcp`'s 17-tool MCP surface. Named methods for build tools
175175
and formatters (`task`, `build`, `format`, `lint-files`, …).
176+
- Subcommands shared across wrappers: `start`, `call`, `tools`, `stop`,
177+
`status`, `prune`. `prune` reaps state dirs whose process is dead
178+
and whose port is unreachable — useful after worktree-remove cycles
179+
pile up stale entries.
180+
- Error-message contract: `lsp call failed` and `tools/call failed`
181+
surface the failing method name and point at the next debugging
182+
step (`<wrapper> tools` to verify method shape, `<wrapper> status`
183+
to verify daemon health). Avoid retrying with `grep` on hook block —
184+
always come back through the wrapper.
176185

177186
Full spec: [`docs/convention.md`](docs/convention.md) ·
178187
[`docs/architecture.md`](docs/architecture.md) ·

bin/cs-direct

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ cmd_call() {
8787
payload="$(jq -cn --arg method "$method" --argjson params "$params_json" '{method:$method,params:$params}')"
8888
curl -fsS -m 120 "http://localhost:$port/lsp" -X POST \
8989
-H 'Content-Type: application/json' \
90-
-d "$payload" || die "lsp call failed"
90+
-d "$payload" || die "lsp call failed (method=$method) — verify method name via '$0 tools' and daemon health via '$0 status'"
9191
echo
9292
}
9393

@@ -147,12 +147,31 @@ cmd_status() {
147147
[ "$any" = 0 ] && echo "no servers tracked"
148148
}
149149

150+
cmd_prune() {
151+
[ -d "$STATE_ROOT" ] || { echo "no state dir"; return; }
152+
local removed=0
153+
for dir in "$STATE_ROOT"/*/; do
154+
[ -d "$dir" ] || continue
155+
local pid port alive=0
156+
pid="$(state_get "$dir" pid)"
157+
port="$(state_get "$dir" port)"
158+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
159+
if [ "$alive" = 0 ] && [ -n "$port" ] && port_ready "$port"; then alive=1; fi
160+
if [ "$alive" = 0 ]; then
161+
rm -rf "$dir"
162+
removed=$((removed+1))
163+
fi
164+
done
165+
echo "pruned $removed dead state dir(s) from $STATE_ROOT"
166+
}
167+
150168
case "${1:-}" in
151169
start) shift; cmd_start "$@" ;;
152170
call) shift; cmd_call "$@" ;;
153171
tools) shift; cmd_tools "$@" ;;
154172
stop) shift; cmd_stop "$@" ;;
155173
status) shift; cmd_status "$@" ;;
174+
prune) shift; cmd_prune "$@" ;;
156175
""|-h|--help)
157176
cat <<EOF
158177
cs-direct — proxy csharp-ls over persistent HTTP; one server per workspace
@@ -163,6 +182,7 @@ usage:
163182
cs-direct tools list LSP method surface
164183
cs-direct stop [workspace] kill server for workspace
165184
cs-direct status show all tracked servers
185+
cs-direct prune remove state dirs for dead servers
166186
167187
workspace markers (walk-up order): ${WORKSPACE_MARKERS[*]}
168188

bin/cs-roslyn-direct

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env bash
2+
# cs-roslyn-direct — proxy Microsoft Roslyn Language Server over persistent HTTP
3+
# uses the binary shipped with VS Code C# Dev Kit / ms-dotnettools.csharp extension
4+
# faster cold-start than csharp-ls (uses Microsoft.CodeAnalysis.LanguageServer directly)
5+
# subcommands: start [workspace] | call <method> <json-params> [workspace] | stop [workspace] | status | tools [workspace] | prune
6+
set -euo pipefail
7+
8+
STATE_ROOT="${CS_ROSLYN_DIRECT_STATE:-$HOME/.cache/cs-roslyn-direct}"
9+
PROXY="$HOME/.claude/bin/lsp-stdio-proxy.js"
10+
11+
# locate Roslyn LS binary: env override → highest-version arm64 VSCode csharp ext → any platform variant
12+
if [ -n "${CS_ROSLYN_BIN:-}" ]; then
13+
LSP_BIN="$CS_ROSLYN_BIN"
14+
else
15+
LSP_BIN="$(ls -d "$HOME"/.vscode/extensions/ms-dotnettools.csharp-*-darwin-arm64/.roslyn/Microsoft.CodeAnalysis.LanguageServer 2>/dev/null | sort -V | tail -1 || true)"
16+
[ -z "$LSP_BIN" ] && LSP_BIN="$(ls -d "$HOME"/.vscode/extensions/ms-dotnettools.csharp-*/.roslyn/Microsoft.CodeAnalysis.LanguageServer 2>/dev/null | sort -V | tail -1 || true)"
17+
fi
18+
19+
# Roslyn args: --stdio for proxy IPC, --logLevel Warning to suppress info noise
20+
LSP_ARGS=(--stdio --logLevel Warning)
21+
LANG_ID="csharp"
22+
WORKSPACE_MARKERS=(.slnx .sln .csproj)
23+
24+
die() { echo "cs-roslyn-direct: $*" >&2; exit 1; }
25+
26+
resolve_workspace() {
27+
local ws="${1:-}"
28+
if [ -n "$ws" ]; then ws="$(cd "$ws" && pwd)"; echo "$ws"; return; fi
29+
ws="$PWD"
30+
while [ "$ws" != "/" ]; do
31+
for marker in "${WORKSPACE_MARKERS[@]}"; do
32+
[ -e "$ws/$marker" ] && { echo "$ws"; return; }
33+
done
34+
ws="$(dirname "$ws")"
35+
done
36+
echo "$PWD"
37+
}
38+
39+
state_dir() {
40+
local hash
41+
hash="$(printf '%s' "$1" | shasum | awk '{print $1}' | cut -c1-12)"
42+
echo "$STATE_ROOT/$hash"
43+
}
44+
45+
free_port() {
46+
python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'
47+
}
48+
49+
state_get() { [ -f "$1/$2" ] && cat "$1/$2" || echo ""; }
50+
51+
port_ready() {
52+
curl -fsS -m 2 "http://127.0.0.1:$1/health" >/dev/null 2>&1
53+
}
54+
55+
server_alive() {
56+
local port; port="$(state_get "$1" port)"
57+
[ -z "$port" ] && return 1
58+
port_ready "$port"
59+
}
60+
61+
cmd_start() {
62+
[ -n "$LSP_BIN" ] && [ -x "$LSP_BIN" ] || die "Roslyn LS binary not found — install VS Code C# extension (ms-dotnettools.csharp) or set CS_ROSLYN_BIN env to absolute path"
63+
[ -f "$PROXY" ] || die "proxy missing: $PROXY"
64+
local ws dir port pid
65+
ws="$(resolve_workspace "${1:-}")"
66+
dir="$(state_dir "$ws")"
67+
mkdir -p "$dir"
68+
if server_alive "$dir"; then
69+
echo "already running: workspace=$ws port=$(state_get "$dir" port) pid=$(state_get "$dir" pid)"
70+
return 0
71+
fi
72+
port="$(free_port)"
73+
echo "$ws" > "$dir/workspace"
74+
echo "$port" > "$dir/port"
75+
nohup node "$PROXY" --tool-name cs-roslyn-direct --workspace "$ws" --port "$port" --lang-id "$LANG_ID" -- "$LSP_BIN" --extensionLogDirectory "$dir" ${LSP_ARGS[@]+"${LSP_ARGS[@]}"} >"$dir/log" 2>&1 &
76+
pid=$!
77+
echo "$pid" > "$dir/pid"
78+
local i=0
79+
until port_ready "$port"; do
80+
sleep 1
81+
i=$((i+1))
82+
[ "$i" -ge 180 ] && { kill "$pid" 2>/dev/null; die "proxy did not bind port $port within 180s — check $dir/log"; }
83+
done
84+
echo "started: workspace=$ws port=$port pid=$pid state=$dir"
85+
}
86+
87+
cmd_call() {
88+
local method="${1:-}"; shift || true
89+
local params_json="${1-}"; shift || true
90+
[ -z "$params_json" ] && params_json='{}'
91+
[ -z "$method" ] && die "usage: cs-roslyn-direct call <method> '<json-params>' [workspace]"
92+
local ws dir port
93+
ws="$(resolve_workspace "${1:-}")"
94+
dir="$(state_dir "$ws")"
95+
server_alive "$dir" || { echo "server not running for $ws — starting..." >&2; cmd_start "$ws" >&2; }
96+
port="$(state_get "$dir" port)"
97+
local payload
98+
payload="$(jq -cn --arg method "$method" --argjson params "$params_json" '{method:$method,params:$params}')"
99+
curl -fsS -m 120 "http://localhost:$port/lsp" -X POST \
100+
-H 'Content-Type: application/json' \
101+
-d "$payload" || die "lsp call failed (method=$method) — verify method name via '$0 tools' and daemon health via '$0 status'"
102+
echo
103+
}
104+
105+
cmd_tools() {
106+
cat <<EOF
107+
cs-roslyn-direct exposes raw LSP methods (Microsoft Roslyn LS surface).
108+
invoke: cs-roslyn-direct call <method> '<json-params>' [workspace]
109+
110+
common methods:
111+
textDocument/documentSymbol outline of a .cs file
112+
textDocument/hover type + xmldoc at position
113+
textDocument/definition jump to definition
114+
textDocument/references find references
115+
textDocument/typeDefinition jump to type
116+
textDocument/implementation find implementations
117+
textDocument/completion completions at position
118+
textDocument/signatureHelp call signature
119+
textDocument/prepareCallHierarchy get call-hierarchy handle
120+
callHierarchy/incomingCalls callers
121+
callHierarchy/outgoingCalls callees
122+
workspace/symbol fuzzy search across workspace
123+
124+
params MUST include textDocument.uri as file://<abs-path> for textDocument/* methods.
125+
126+
example:
127+
cs-roslyn-direct call textDocument/documentSymbol '{"textDocument":{"uri":"file:///path/to/File.cs"}}'
128+
EOF
129+
}
130+
131+
cmd_stop() {
132+
local ws dir pid
133+
ws="$(resolve_workspace "${1:-}")"
134+
dir="$(state_dir "$ws")"
135+
pid="$(state_get "$dir" pid)"
136+
if [ -n "$pid" ]; then
137+
kill "$pid" 2>/dev/null && echo "stopped: pid=$pid workspace=$ws" || echo "kill failed (pid=$pid) — state cleared for $ws"
138+
else
139+
echo "no pid for $ws"
140+
fi
141+
rm -f "$dir/pid" "$dir/port"
142+
}
143+
144+
cmd_status() {
145+
[ -d "$STATE_ROOT" ] || { echo "no servers tracked"; return; }
146+
local any=0
147+
for dir in "$STATE_ROOT"/*/; do
148+
[ -d "$dir" ] || continue
149+
any=1
150+
local ws pid port alive
151+
ws="$(state_get "$dir" workspace)"
152+
pid="$(state_get "$dir" pid)"
153+
port="$(state_get "$dir" port)"
154+
alive="dead"
155+
[ -n "$port" ] && port_ready "$port" && alive="alive"
156+
printf 'workspace=%s port=%s pid=%s %s\n' "$ws" "$port" "$pid" "$alive"
157+
done
158+
[ "$any" = 0 ] && echo "no servers tracked"
159+
}
160+
161+
cmd_prune() {
162+
[ -d "$STATE_ROOT" ] || { echo "no state dir"; return; }
163+
local removed=0
164+
for dir in "$STATE_ROOT"/*/; do
165+
[ -d "$dir" ] || continue
166+
local pid port alive=0
167+
pid="$(state_get "$dir" pid)"
168+
port="$(state_get "$dir" port)"
169+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
170+
if [ "$alive" = 0 ] && [ -n "$port" ] && port_ready "$port"; then alive=1; fi
171+
if [ "$alive" = 0 ]; then
172+
rm -rf "$dir"
173+
removed=$((removed+1))
174+
fi
175+
done
176+
echo "pruned $removed dead state dir(s) from $STATE_ROOT"
177+
}
178+
179+
case "${1:-}" in
180+
start) shift; cmd_start "$@" ;;
181+
call) shift; cmd_call "$@" ;;
182+
tools) shift; cmd_tools "$@" ;;
183+
stop) shift; cmd_stop "$@" ;;
184+
status) shift; cmd_status "$@" ;;
185+
prune) shift; cmd_prune "$@" ;;
186+
""|-h|--help)
187+
cat <<EOF
188+
cs-roslyn-direct — proxy Microsoft Roslyn LS over persistent HTTP; one server per workspace
189+
190+
usage:
191+
cs-roslyn-direct start [workspace] spawn proxy for workspace (default: cwd walking up for .slnx/.sln/.csproj)
192+
cs-roslyn-direct call <method> '<json-params>' [ws] issue raw LSP method — auto-starts server
193+
cs-roslyn-direct tools list LSP method surface
194+
cs-roslyn-direct stop [workspace] kill server for workspace
195+
cs-roslyn-direct status show all tracked servers
196+
cs-roslyn-direct prune remove state dirs for dead servers
197+
198+
workspace markers (walk-up order): ${WORKSPACE_MARKERS[*]}
199+
200+
state: $STATE_ROOT/<workspace-hash>/{pid,port,workspace,log}
201+
EOF
202+
;;
203+
*) die "unknown subcommand: $1 (try --help)" ;;
204+
esac

bin/java-direct

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ cmd_call() {
8888
payload="$(jq -cn --arg method "$method" --argjson params "$params_json" '{method:$method,params:$params}')"
8989
curl -fsS -m 120 "http://localhost:$port/lsp" -X POST \
9090
-H 'Content-Type: application/json' \
91-
-d "$payload" || die "lsp call failed"
91+
-d "$payload" || die "lsp call failed (method=$method) — verify method name via '$0 tools' and daemon health via '$0 status'"
9292
echo
9393
}
9494

@@ -149,12 +149,31 @@ cmd_status() {
149149
[ "$any" = 0 ] && echo "no servers tracked"
150150
}
151151

152+
cmd_prune() {
153+
[ -d "$STATE_ROOT" ] || { echo "no state dir"; return; }
154+
local removed=0
155+
for dir in "$STATE_ROOT"/*/; do
156+
[ -d "$dir" ] || continue
157+
local pid port alive=0
158+
pid="$(state_get "$dir" pid)"
159+
port="$(state_get "$dir" port)"
160+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
161+
if [ "$alive" = 0 ] && [ -n "$port" ] && port_ready "$port"; then alive=1; fi
162+
if [ "$alive" = 0 ]; then
163+
rm -rf "$dir"
164+
removed=$((removed+1))
165+
fi
166+
done
167+
echo "pruned $removed dead state dir(s) from $STATE_ROOT"
168+
}
169+
152170
case "${1:-}" in
153171
start) shift; cmd_start "$@" ;;
154172
call) shift; cmd_call "$@" ;;
155173
tools) shift; cmd_tools "$@" ;;
156174
stop) shift; cmd_stop "$@" ;;
157175
status) shift; cmd_status "$@" ;;
176+
prune) shift; cmd_prune "$@" ;;
158177
""|-h|--help)
159178
cat <<EOF
160179
java-direct — proxy jdtls (Eclipse JDT.LS) over persistent HTTP; one server per workspace
@@ -165,6 +184,7 @@ usage:
165184
java-direct tools list LSP method surface
166185
java-direct stop [workspace] kill server for workspace
167186
java-direct status show all tracked servers
187+
java-direct prune remove state dirs for dead servers
168188
169189
workspace markers (walk-up order): ${WORKSPACE_MARKERS[*]}
170190

bin/metals-direct

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,37 @@ cmd_status() {
200200
port="$(state_get "$dir" port)"
201201
alive="dead"
202202
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && alive="alive"
203+
[ "$alive" = "dead" ] && [ -z "$pid" ] && [ -n "$port" ] && port_ready "$port" && alive="adopted"
203204
printf 'workspace=%s port=%s pid=%s %s\n' "$ws" "$port" "$pid" "$alive"
204205
done
205206
[ "$any" = 0 ] && echo "no servers tracked"
206207
}
207208

209+
cmd_prune() {
210+
[ -d "$STATE_ROOT" ] || { echo "no state dir"; return; }
211+
local removed=0
212+
for dir in "$STATE_ROOT"/*/; do
213+
[ -d "$dir" ] || continue
214+
local pid port alive=0
215+
pid="$(state_get "$dir" pid)"
216+
port="$(state_get "$dir" port)"
217+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
218+
if [ "$alive" = 0 ] && [ -n "$port" ] && port_ready "$port"; then alive=1; fi
219+
if [ "$alive" = 0 ]; then
220+
rm -rf "$dir"
221+
removed=$((removed+1))
222+
fi
223+
done
224+
echo "pruned $removed dead state dir(s) from $STATE_ROOT"
225+
}
226+
208227
case "${1:-}" in
209228
start) shift; cmd_start "$@" ;;
210229
call) shift; cmd_call "$@" ;;
211230
tools) shift; cmd_tools "$@" ;;
212231
stop) shift; cmd_stop "$@" ;;
213232
status) shift; cmd_status "$@" ;;
233+
prune) shift; cmd_prune "$@" ;;
214234
""|-h|--help)
215235
cat <<EOF
216236
metals-direct — talk to metals-mcp over direct HTTP, bypass claude MCP client overhead
@@ -221,6 +241,7 @@ usage:
221241
metals-direct tools [workspace] list available tools (list-modules, get-usages, get-source, get-docs, inspect, glob-search, ...)
222242
metals-direct stop [workspace] kill server for workspace
223243
metals-direct status show all tracked servers
244+
metals-direct prune remove state dirs for dead servers
224245
225246
examples:
226247
cd ~/projects/your-scala-project && metals-direct start

0 commit comments

Comments
 (0)