@synkro-sh/cli 1.3.58 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.js +1634 -815
- package/dist/bootstrap.js.map +1 -1
- package/package.json +3 -4
package/dist/bootstrap.js
CHANGED
|
@@ -324,6 +324,11 @@ var init_hookScripts = __esm({
|
|
|
324
324
|
|
|
325
325
|
synkro_log() { echo "[synkro] $1" >&2; }
|
|
326
326
|
|
|
327
|
+
# True if anything is listening on the local-cc channel TCP port.
|
|
328
|
+
synkro_channel_up() {
|
|
329
|
+
(exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
|
|
330
|
+
}
|
|
331
|
+
|
|
327
332
|
# Load config
|
|
328
333
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
329
334
|
if [ -f "$CONFIG_FILE" ]; then
|
|
@@ -609,10 +614,12 @@ if command -v claude >/dev/null 2>&1; then
|
|
|
609
614
|
USE_LOCAL=true
|
|
610
615
|
fi
|
|
611
616
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
617
|
+
# Prefer local grading whenever it's available \u2014 the local-cc channel beats
|
|
618
|
+
# the cloud fast tier on latency, and it's available regardless of the
|
|
619
|
+
# server-assigned tier. Fall through to the cloud curl only when neither
|
|
620
|
+
# the channel nor a usable \`claude\` CLI is reachable.
|
|
621
|
+
if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
|
|
622
|
+
# \u2500\u2500\u2500 LOCAL PATH: channel-grader first, then \`claude --print\` cold fallback. \u2500\u2500\u2500
|
|
616
623
|
|
|
617
624
|
RULES_CACHE="$HOME/.synkro/.rules-cache-bash"
|
|
618
625
|
ORG_RULES="[]"
|
|
@@ -644,9 +651,16 @@ if [ "$USE_LOCAL" = "true" ]; then
|
|
|
644
651
|
printf 'Recent actions: %s\\n' "$RECENT_ACTIONS" >> "$GRADER_PROMPT_FILE"
|
|
645
652
|
printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
|
|
646
653
|
|
|
647
|
-
|
|
648
|
-
|
|
654
|
+
ROUTE_TAG=""
|
|
655
|
+
if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
656
|
+
ROUTE_TAG="local-cc"
|
|
657
|
+
CC_RESP=$(node "$SYNKRO_CLI_BIN" grade bash < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
658
|
+
elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
|
|
659
|
+
ROUTE_TAG="local-cc-path"
|
|
660
|
+
CC_RESP=$(synkro grade bash < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
649
661
|
else
|
|
662
|
+
# Channel unavailable \u2014 fall back to cold \`claude --print\` (free tier path).
|
|
663
|
+
ROUTE_TAG="cc-print"
|
|
650
664
|
CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
651
665
|
fi
|
|
652
666
|
# Wrapper extraction \u2014 greedy so it tolerates nested XML tags inside.
|
|
@@ -696,7 +710,7 @@ fi
|
|
|
696
710
|
|
|
697
711
|
if [ -z "$VERDICT" ]; then
|
|
698
712
|
synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
|
|
699
|
-
jq -n --arg m "[synkro] bashGuard \u2192 error (timeout)" '{systemMessage: $m}'
|
|
713
|
+
jq -n --arg m "[synkro:\${ROUTE_TAG:-cloud}] bashGuard \u2192 error (timeout)" '{systemMessage: $m}'
|
|
700
714
|
exit 0
|
|
701
715
|
fi
|
|
702
716
|
|
|
@@ -716,6 +730,7 @@ SEVERITY="\${SEVERITY:-audit}"
|
|
|
716
730
|
VERDICT_KIND="\${VERDICT_KIND:-warn}"
|
|
717
731
|
REASONING="\${REASONING:-matched dangerous-verb regex}"
|
|
718
732
|
CATEGORY="\${CATEGORY:-destructive_command}"
|
|
733
|
+
SYNKRO_PREFIX="[synkro:\${ROUTE_TAG:-cloud}]"
|
|
719
734
|
|
|
720
735
|
# Backwards-compat: if severity isn't block/audit, derive it from verdict_kind
|
|
721
736
|
# and treat the original severity as the risk_level.
|
|
@@ -741,8 +756,8 @@ fi
|
|
|
741
756
|
|
|
742
757
|
case "$SEVERITY" in
|
|
743
758
|
block)
|
|
744
|
-
PERMISSION_REASON="
|
|
745
|
-
ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
|
|
759
|
+
PERMISSION_REASON="\${SYNKRO_PREFIX} \${REASONING}\${ALT_SUFFIX}"
|
|
760
|
+
ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}, route: \${ROUTE_TAG:-cloud}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
|
|
746
761
|
if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
|
|
747
762
|
jq -n \\
|
|
748
763
|
--arg ctx "$ADDITIONAL_CTX" \\
|
|
@@ -761,11 +776,11 @@ case "$SEVERITY" in
|
|
|
761
776
|
synkro_log "bashGuard $CMD_SHORT \u2192 pass ($CATEGORY): $REASONING"
|
|
762
777
|
case "$CATEGORY" in
|
|
763
778
|
trivial_utility)
|
|
764
|
-
jq -n --arg m "
|
|
779
|
+
jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass" '{systemMessage: $m}' ;;
|
|
765
780
|
judge_unavailable|judge_error|parse_error)
|
|
766
|
-
jq -n --arg m "
|
|
781
|
+
jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass (grader unavailable)" '{systemMessage: $m}' ;;
|
|
767
782
|
*)
|
|
768
|
-
jq -n --arg m "
|
|
783
|
+
jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass (\${CATEGORY}): \${REASONING}" '{systemMessage: $m}' ;;
|
|
769
784
|
esac
|
|
770
785
|
;;
|
|
771
786
|
*)
|
|
@@ -773,7 +788,7 @@ case "$SEVERITY" in
|
|
|
773
788
|
if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
|
|
774
789
|
jq -n \\
|
|
775
790
|
--arg decision "$DECISION" \\
|
|
776
|
-
--arg reason "
|
|
791
|
+
--arg reason "\${SYNKRO_PREFIX} unexpected severity '\${SEVERITY}' \u2014 blocking by default. Please email team@synkro.sh to report this issue." \\
|
|
777
792
|
'{
|
|
778
793
|
hookSpecificOutput: {
|
|
779
794
|
hookEventName: "PreToolUse",
|
|
@@ -865,6 +880,11 @@ exit 0
|
|
|
865
880
|
|
|
866
881
|
synkro_log() { echo "[synkro] $1" >&2; }
|
|
867
882
|
|
|
883
|
+
# True if anything is listening on the local-cc channel TCP port.
|
|
884
|
+
synkro_channel_up() {
|
|
885
|
+
(exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
|
|
886
|
+
}
|
|
887
|
+
|
|
868
888
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
869
889
|
if [ -f "$CONFIG_FILE" ]; then
|
|
870
890
|
set -a
|
|
@@ -1110,15 +1130,8 @@ fi
|
|
|
1110
1130
|
SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-fast}"
|
|
1111
1131
|
SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-full}"
|
|
1112
1132
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
USE_LOCAL=true
|
|
1116
|
-
fi
|
|
1117
|
-
|
|
1118
|
-
if [ "$USE_LOCAL" = "true" ]; then
|
|
1119
|
-
# \u2500\u2500\u2500 LOCAL GRADING: grade via the persistent claude daemon (mode=edit).
|
|
1120
|
-
# The daemon fetches the primer (Synkro IP) directly from the server at
|
|
1121
|
-
# startup and holds it in memory \u2014 never written to disk.
|
|
1133
|
+
if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
|
|
1134
|
+
# \u2500\u2500\u2500 LOCAL PATH: channel-grader first, then \`claude --print\` cold fallback. \u2500\u2500\u2500
|
|
1122
1135
|
RULES_CACHE="$HOME/.synkro/.rules-cache-edit"
|
|
1123
1136
|
ORG_RULES="[]"
|
|
1124
1137
|
if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then
|
|
@@ -1149,8 +1162,12 @@ if [ "$USE_LOCAL" = "true" ]; then
|
|
|
1149
1162
|
printf 'Diff:\\n' >> "$GRADER_PROMPT_FILE"
|
|
1150
1163
|
printf '%s\\n' "$PROPOSED" >> "$GRADER_PROMPT_FILE"
|
|
1151
1164
|
|
|
1152
|
-
if [ -
|
|
1153
|
-
|
|
1165
|
+
if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
1166
|
+
synkro_log "editGuard $FILE_SHORT \u2192 routing via local-cc"
|
|
1167
|
+
CC_RESP=$(node "$SYNKRO_CLI_BIN" grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1168
|
+
elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
|
|
1169
|
+
synkro_log "editGuard $FILE_SHORT \u2192 routing via local-cc (PATH)"
|
|
1170
|
+
CC_RESP=$(synkro grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1154
1171
|
else
|
|
1155
1172
|
CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1156
1173
|
fi
|
|
@@ -1371,6 +1388,11 @@ exit 0
|
|
|
1371
1388
|
|
|
1372
1389
|
synkro_log() { echo "[synkro] $1" >&2; }
|
|
1373
1390
|
|
|
1391
|
+
# True if anything is listening on the local-cc channel TCP port.
|
|
1392
|
+
synkro_channel_up() {
|
|
1393
|
+
(exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1374
1396
|
CONFIG_FILE="$HOME/.synkro/config.env"
|
|
1375
1397
|
if [ -f "$CONFIG_FILE" ]; then
|
|
1376
1398
|
set -a
|
|
@@ -1566,13 +1588,8 @@ fi
|
|
|
1566
1588
|
SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-fast}"
|
|
1567
1589
|
SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-full}"
|
|
1568
1590
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
USE_LOCAL=true
|
|
1572
|
-
fi
|
|
1573
|
-
|
|
1574
|
-
if [ "$USE_LOCAL" = "true" ]; then
|
|
1575
|
-
# \u2500\u2500\u2500 LOCAL GRADING: grade via the persistent claude daemon (mode=edit). \u2500\u2500\u2500
|
|
1591
|
+
if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
|
|
1592
|
+
# \u2500\u2500\u2500 LOCAL PATH: channel-grader first, then \`claude --print\` cold fallback. \u2500\u2500\u2500
|
|
1576
1593
|
|
|
1577
1594
|
RULES_CACHE="$HOME/.synkro/.rules-cache-edit-capture"
|
|
1578
1595
|
RULES_RESP=""
|
|
@@ -1617,8 +1634,12 @@ if [ "$USE_LOCAL" = "true" ]; then
|
|
|
1617
1634
|
printf 'Content:\\n' >> "$GRADER_PROMPT_FILE"
|
|
1618
1635
|
printf '%s\\n' "$FILE_CONTENT" >> "$GRADER_PROMPT_FILE"
|
|
1619
1636
|
|
|
1620
|
-
if [ -
|
|
1621
|
-
|
|
1637
|
+
if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
1638
|
+
synkro_log "editGuard $BASENAME \u2192 routing via local-cc"
|
|
1639
|
+
CC_RESP=$(node "$SYNKRO_CLI_BIN" grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1640
|
+
elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
|
|
1641
|
+
synkro_log "editGuard $BASENAME \u2192 routing via local-cc (PATH)"
|
|
1642
|
+
CC_RESP=$(synkro grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1622
1643
|
else
|
|
1623
1644
|
CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
|
|
1624
1645
|
fi
|
|
@@ -1919,20 +1940,31 @@ fi
|
|
|
1919
1940
|
GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
|
|
1920
1941
|
CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
|
|
1921
1942
|
|
|
1943
|
+
# Route preamble \u2014 tell the user (and CC) which inference path will be used.
|
|
1944
|
+
# We probe the local-cc TCP listener directly so the line reflects ground truth
|
|
1945
|
+
# rather than just the persisted toggle.
|
|
1946
|
+
SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
|
|
1947
|
+
if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
|
|
1948
|
+
exec 3<&- 3>&- 2>/dev/null || true
|
|
1949
|
+
ROUTE_LINE="[synkro] inference: local-cc (channel reachable on 127.0.0.1:$SYNKRO_PORT)"
|
|
1950
|
+
else
|
|
1951
|
+
ROUTE_LINE="[synkro] inference: cloud (local-cc channel not reachable)"
|
|
1952
|
+
fi
|
|
1953
|
+
|
|
1922
1954
|
if [ ! -f "$CREDS_PATH" ]; then
|
|
1923
|
-
|
|
1955
|
+
jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
|
|
1924
1956
|
exit 0
|
|
1925
1957
|
fi
|
|
1926
1958
|
JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
|
|
1927
1959
|
if [ -z "$JWT" ]; then
|
|
1928
|
-
|
|
1960
|
+
jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
|
|
1929
1961
|
exit 0
|
|
1930
1962
|
fi
|
|
1931
1963
|
|
|
1932
1964
|
PAYLOAD=$(cat)
|
|
1933
1965
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
1934
1966
|
if [ -z "$CWD" ]; then
|
|
1935
|
-
|
|
1967
|
+
jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
|
|
1936
1968
|
exit 0
|
|
1937
1969
|
fi
|
|
1938
1970
|
|
|
@@ -1951,7 +1983,7 @@ RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-context" \\
|
|
|
1951
1983
|
--max-time 2 2>/dev/null || echo "")
|
|
1952
1984
|
|
|
1953
1985
|
if [ -z "$RESP" ]; then
|
|
1954
|
-
|
|
1986
|
+
jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
|
|
1955
1987
|
exit 0
|
|
1956
1988
|
fi
|
|
1957
1989
|
|
|
@@ -1959,14 +1991,14 @@ PLAN_NUDGE="Before implementing any multi-step plan, call the synkro-guardrails
|
|
|
1959
1991
|
|
|
1960
1992
|
OPEN=$(echo "$RESP" | jq -r '.open_count // 0' 2>/dev/null)
|
|
1961
1993
|
if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
|
|
1962
|
-
jq -n --arg sys_msg "[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
|
|
1994
|
+
jq -n --arg sys_msg "$ROUTE_LINE"$'\\n'"[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
|
|
1963
1995
|
exit 0
|
|
1964
1996
|
fi
|
|
1965
1997
|
|
|
1966
1998
|
if [ "$OPEN" = "1" ]; then
|
|
1967
|
-
SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
|
|
1999
|
+
SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
|
|
1968
2000
|
else
|
|
1969
|
-
SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
|
|
2001
|
+
SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
|
|
1970
2002
|
fi
|
|
1971
2003
|
|
|
1972
2004
|
jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
|
|
@@ -2156,574 +2188,6 @@ exit 0
|
|
|
2156
2188
|
}
|
|
2157
2189
|
});
|
|
2158
2190
|
|
|
2159
|
-
// cli/installer/graderDaemon.ts
|
|
2160
|
-
var GRADER_DAEMON_PY;
|
|
2161
|
-
var init_graderDaemon = __esm({
|
|
2162
|
-
"cli/installer/graderDaemon.ts"() {
|
|
2163
|
-
"use strict";
|
|
2164
|
-
GRADER_DAEMON_PY = `#!/usr/bin/env python3
|
|
2165
|
-
"""
|
|
2166
|
-
Synkro warm-pool grader \u2014 pre-warmed \`claude --print --system-prompt\` process
|
|
2167
|
-
pool fronted by a Unix socket. Each grade uses one warm process and kills it;
|
|
2168
|
-
a replacement is pre-warmed in the background.
|
|
2169
|
-
|
|
2170
|
-
Zero context bloat: 1 grade per process, system prompt via --system-prompt flag
|
|
2171
|
-
(single inference call, no primer-as-conversation-turn overhead).
|
|
2172
|
-
|
|
2173
|
-
Warm steady-state: ~2-3s per grade. Cold fallback: ~5-6s if pre-warm not ready.
|
|
2174
|
-
|
|
2175
|
-
Commands:
|
|
2176
|
-
start - bring up daemon if not running (fetches primer from server)
|
|
2177
|
-
grade - read prompt from stdin, write verdict text to stdout
|
|
2178
|
-
stop - terminate daemon
|
|
2179
|
-
status - print "running"/"stopped"
|
|
2180
|
-
"""
|
|
2181
|
-
|
|
2182
|
-
import os, sys, json, socket, time, signal, fcntl, re, select, queue
|
|
2183
|
-
import subprocess, threading, urllib.request, urllib.error
|
|
2184
|
-
from pathlib import Path
|
|
2185
|
-
|
|
2186
|
-
# \u2500\u2500 Primer fetch \u2014 server-side IP, never written to disk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2187
|
-
SYNKRO_HOME = Path.home() / ".synkro"
|
|
2188
|
-
CREDS_PATH = Path(os.environ.get("SYNKRO_CREDENTIALS_PATH") or str(SYNKRO_HOME / "credentials.json"))
|
|
2189
|
-
GATEWAY_URL = os.environ.get("SYNKRO_GATEWAY_URL", "https://api.synkro.sh").rstrip("/")
|
|
2190
|
-
CONFIG_ENV = SYNKRO_HOME / "config.env"
|
|
2191
|
-
|
|
2192
|
-
def _load_gateway_url():
|
|
2193
|
-
"""Read SYNKRO_GATEWAY_URL from config.env if not in env."""
|
|
2194
|
-
global GATEWAY_URL
|
|
2195
|
-
if "SYNKRO_GATEWAY_URL" in os.environ:
|
|
2196
|
-
return
|
|
2197
|
-
try:
|
|
2198
|
-
for line in CONFIG_ENV.read_text().splitlines():
|
|
2199
|
-
line = line.strip()
|
|
2200
|
-
if line.startswith("SYNKRO_GATEWAY_URL="):
|
|
2201
|
-
v = line.split("=", 1)[1].strip().strip("'").strip('"')
|
|
2202
|
-
if v.startswith(("http://", "https://")):
|
|
2203
|
-
GATEWAY_URL = v.rstrip("/")
|
|
2204
|
-
return
|
|
2205
|
-
except Exception:
|
|
2206
|
-
pass
|
|
2207
|
-
|
|
2208
|
-
def _read_jwt():
|
|
2209
|
-
try:
|
|
2210
|
-
with open(CREDS_PATH) as f:
|
|
2211
|
-
return json.load(f).get("access_token", "") or ""
|
|
2212
|
-
except Exception:
|
|
2213
|
-
return ""
|
|
2214
|
-
|
|
2215
|
-
def _refresh_jwt():
|
|
2216
|
-
"""Refresh the access token using the saved refresh_token. Writes new
|
|
2217
|
-
tokens back to credentials.json. Returns the new access_token or ''."""
|
|
2218
|
-
try:
|
|
2219
|
-
with open(CREDS_PATH) as f:
|
|
2220
|
-
creds = json.load(f)
|
|
2221
|
-
rt = creds.get("refresh_token", "")
|
|
2222
|
-
if not rt:
|
|
2223
|
-
return ""
|
|
2224
|
-
req = urllib.request.Request(
|
|
2225
|
-
f"{GATEWAY_URL}/api/auth/refresh",
|
|
2226
|
-
data=json.dumps({"refresh_token": rt}).encode("utf-8"),
|
|
2227
|
-
headers={
|
|
2228
|
-
"Content-Type": "application/json",
|
|
2229
|
-
"User-Agent": "synkro-cli-grader-daemon/1",
|
|
2230
|
-
},
|
|
2231
|
-
)
|
|
2232
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
2233
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
2234
|
-
new_at = data.get("access_token", "")
|
|
2235
|
-
new_rt = data.get("refresh_token", "") or rt
|
|
2236
|
-
if not new_at:
|
|
2237
|
-
return ""
|
|
2238
|
-
creds["access_token"] = new_at
|
|
2239
|
-
creds["refresh_token"] = new_rt
|
|
2240
|
-
tmp = str(CREDS_PATH) + ".synkro.tmp"
|
|
2241
|
-
with open(tmp, "w") as f:
|
|
2242
|
-
json.dump(creds, f)
|
|
2243
|
-
os.replace(tmp, str(CREDS_PATH))
|
|
2244
|
-
return new_at
|
|
2245
|
-
except Exception:
|
|
2246
|
-
return ""
|
|
2247
|
-
|
|
2248
|
-
def fetch_primer(mode):
|
|
2249
|
-
"""Fetch primer text for {bash,edit} from /cli/judge-prompts. In-memory only.
|
|
2250
|
-
Auto-refreshes JWT on 401 and retries once."""
|
|
2251
|
-
_load_gateway_url()
|
|
2252
|
-
field = "grader_primer_bash" if mode == "bash" else "grader_primer_edit"
|
|
2253
|
-
|
|
2254
|
-
def _do_fetch(jwt):
|
|
2255
|
-
req = urllib.request.Request(
|
|
2256
|
-
f"{GATEWAY_URL}/api/v1/cli/judge-prompts",
|
|
2257
|
-
headers={
|
|
2258
|
-
"Authorization": f"Bearer {jwt}",
|
|
2259
|
-
"User-Agent": "synkro-cli-grader-daemon/1",
|
|
2260
|
-
},
|
|
2261
|
-
)
|
|
2262
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
2263
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
2264
|
-
return data.get(field, "") or ""
|
|
2265
|
-
|
|
2266
|
-
jwt = _read_jwt()
|
|
2267
|
-
if not jwt:
|
|
2268
|
-
return ""
|
|
2269
|
-
try:
|
|
2270
|
-
return _do_fetch(jwt)
|
|
2271
|
-
except urllib.error.HTTPError as e:
|
|
2272
|
-
if e.code != 401:
|
|
2273
|
-
return ""
|
|
2274
|
-
# Token expired \u2014 refresh and retry once.
|
|
2275
|
-
new_jwt = _refresh_jwt()
|
|
2276
|
-
if not new_jwt:
|
|
2277
|
-
return ""
|
|
2278
|
-
try:
|
|
2279
|
-
return _do_fetch(new_jwt)
|
|
2280
|
-
except Exception:
|
|
2281
|
-
return ""
|
|
2282
|
-
except Exception:
|
|
2283
|
-
return ""
|
|
2284
|
-
|
|
2285
|
-
ALLOWED_MODE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$")
|
|
2286
|
-
DAEMON_BASE = Path.home() / ".synkro" / "daemon"
|
|
2287
|
-
DAEMON_BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
2288
|
-
|
|
2289
|
-
def mode_paths(mode):
|
|
2290
|
-
d = DAEMON_BASE / mode
|
|
2291
|
-
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
2292
|
-
return d / "daemon.pid", d / "daemon.sock", d / "daemon.log"
|
|
2293
|
-
|
|
2294
|
-
MODE = "edit"
|
|
2295
|
-
PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
|
|
2296
|
-
|
|
2297
|
-
GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "45"))
|
|
2298
|
-
DEFAULT_MODEL = os.environ.get("SYNKRO_DAEMON_MODEL", "claude-sonnet-4-6")
|
|
2299
|
-
MAX_PROMPT_BYTES = 4 * 1024 * 1024
|
|
2300
|
-
IDLE_SHUTDOWN_SEC = int(os.environ.get("SYNKRO_DAEMON_IDLE_TIMEOUT", "600"))
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
def log(msg):
|
|
2304
|
-
try:
|
|
2305
|
-
with open(LOG_FILE, "a") as f:
|
|
2306
|
-
f.write(f"[{time.strftime('%H:%M:%S')} pid={os.getpid()}] {msg}\\n")
|
|
2307
|
-
except Exception:
|
|
2308
|
-
pass
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
STALL_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_STALL_TIMEOUT", "15"))
|
|
2312
|
-
|
|
2313
|
-
def _read_response(proc, timeout=45, stop_event=None):
|
|
2314
|
-
"""Read stream-json from proc.stdout until a 'result' message arrives.
|
|
2315
|
-
If stop_event is set externally, returns immediately with empty string \u2014
|
|
2316
|
-
used by the parallel-race grade path to abort the loser."""
|
|
2317
|
-
acc = []
|
|
2318
|
-
deadline = time.time() + timeout
|
|
2319
|
-
last_data = time.time()
|
|
2320
|
-
fd = proc.stdout.fileno()
|
|
2321
|
-
buf = ""
|
|
2322
|
-
while True:
|
|
2323
|
-
if stop_event is not None and stop_event.is_set():
|
|
2324
|
-
return ""
|
|
2325
|
-
remaining = deadline - time.time()
|
|
2326
|
-
if remaining <= 0:
|
|
2327
|
-
log("read timeout")
|
|
2328
|
-
return ""
|
|
2329
|
-
if time.time() - last_data > STALL_TIMEOUT_SEC:
|
|
2330
|
-
log(f"stall timeout: no data for {STALL_TIMEOUT_SEC}s")
|
|
2331
|
-
return ""
|
|
2332
|
-
ready, _, _ = select.select([fd], [], [], min(remaining, 1.0))
|
|
2333
|
-
if not ready:
|
|
2334
|
-
if proc.poll() is not None:
|
|
2335
|
-
log("process exited during read")
|
|
2336
|
-
return ""
|
|
2337
|
-
continue
|
|
2338
|
-
chunk = os.read(fd, 65536)
|
|
2339
|
-
if not chunk:
|
|
2340
|
-
return ""
|
|
2341
|
-
last_data = time.time()
|
|
2342
|
-
buf += chunk.decode("utf-8", errors="replace")
|
|
2343
|
-
while "\\n" in buf:
|
|
2344
|
-
line, buf = buf.split("\\n", 1)
|
|
2345
|
-
line = line.strip()
|
|
2346
|
-
if not line:
|
|
2347
|
-
continue
|
|
2348
|
-
try:
|
|
2349
|
-
obj = json.loads(line)
|
|
2350
|
-
except json.JSONDecodeError:
|
|
2351
|
-
continue
|
|
2352
|
-
t = obj.get("type")
|
|
2353
|
-
if t == "assistant":
|
|
2354
|
-
for c in obj.get("message", {}).get("content", []):
|
|
2355
|
-
if c.get("type") == "text":
|
|
2356
|
-
acc.append(c["text"])
|
|
2357
|
-
elif t == "result":
|
|
2358
|
-
return "".join(acc)
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
def _send_msg(proc, text, close_stdin=False):
|
|
2362
|
-
"""Send a stream-json user message. If close_stdin=True, close stdin after
|
|
2363
|
-
flushing \u2014 this forces claude --print to stop waiting for more input and
|
|
2364
|
-
process what it has. Required for the final/grade message; the prewarm
|
|
2365
|
-
primer ack must keep stdin open so the warm process stays alive for the
|
|
2366
|
-
upcoming grade call."""
|
|
2367
|
-
msg = json.dumps({
|
|
2368
|
-
"type": "user",
|
|
2369
|
-
"message": {"role": "user", "content": [{"type": "text", "text": text}]},
|
|
2370
|
-
"parent_tool_use_id": None,
|
|
2371
|
-
"session_id": "",
|
|
2372
|
-
})
|
|
2373
|
-
proc.stdin.write(msg + "\\n")
|
|
2374
|
-
proc.stdin.flush()
|
|
2375
|
-
if close_stdin:
|
|
2376
|
-
try: proc.stdin.close()
|
|
2377
|
-
except Exception: pass
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
class WarmGrader:
|
|
2381
|
-
"""
|
|
2382
|
-
Keeps one pre-warmed claude process ready. Each grade pulls the warm
|
|
2383
|
-
process, sends one prompt, reads the verdict, kills the process, and
|
|
2384
|
-
starts pre-warming a replacement in the background.
|
|
2385
|
-
|
|
2386
|
-
The warm process has the system prompt loaded via --system-prompt and
|
|
2387
|
-
its KV cache primed by a warmup turn. The actual grade is a single
|
|
2388
|
-
inference call that benefits from the cached system prompt tokens.
|
|
2389
|
-
"""
|
|
2390
|
-
def __init__(self, primer):
|
|
2391
|
-
self.primer = primer or ""
|
|
2392
|
-
self._warm_proc = None
|
|
2393
|
-
self._warm_ready_at = 0.0
|
|
2394
|
-
self._warm_thread = None
|
|
2395
|
-
self._lock = threading.Lock()
|
|
2396
|
-
self._total_grades = 0
|
|
2397
|
-
self._prewarm_ok = True
|
|
2398
|
-
self._start_prewarm()
|
|
2399
|
-
|
|
2400
|
-
def _make_proc(self):
|
|
2401
|
-
cmd = [
|
|
2402
|
-
"claude", "--print", "--model", DEFAULT_MODEL,
|
|
2403
|
-
"--input-format=stream-json",
|
|
2404
|
-
"--output-format=stream-json",
|
|
2405
|
-
"--verbose",
|
|
2406
|
-
"--no-session-persistence",
|
|
2407
|
-
]
|
|
2408
|
-
if self.primer:
|
|
2409
|
-
cmd += ["--system-prompt", self.primer]
|
|
2410
|
-
return subprocess.Popen(
|
|
2411
|
-
cmd,
|
|
2412
|
-
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
2413
|
-
stderr=subprocess.DEVNULL, text=True, bufsize=1,
|
|
2414
|
-
)
|
|
2415
|
-
|
|
2416
|
-
def _kill_proc(self, proc):
|
|
2417
|
-
try: proc.stdin.close()
|
|
2418
|
-
except Exception: pass
|
|
2419
|
-
try: proc.kill(); proc.wait(timeout=2)
|
|
2420
|
-
except Exception: pass
|
|
2421
|
-
|
|
2422
|
-
def _prewarm(self):
|
|
2423
|
-
try:
|
|
2424
|
-
log("pre-warming process")
|
|
2425
|
-
proc = self._make_proc()
|
|
2426
|
-
_send_msg(proc, "Ready")
|
|
2427
|
-
resp = _read_response(proc, timeout=15)
|
|
2428
|
-
if resp:
|
|
2429
|
-
with self._lock:
|
|
2430
|
-
old = self._warm_proc
|
|
2431
|
-
self._warm_proc = proc
|
|
2432
|
-
self._warm_ready_at = time.time()
|
|
2433
|
-
self._prewarm_ok = True
|
|
2434
|
-
if old:
|
|
2435
|
-
self._kill_proc(old)
|
|
2436
|
-
log(f"pre-warm ready ({len(resp)} chars)")
|
|
2437
|
-
else:
|
|
2438
|
-
log("pre-warm response empty")
|
|
2439
|
-
self._kill_proc(proc)
|
|
2440
|
-
self._prewarm_ok = False
|
|
2441
|
-
except Exception as e:
|
|
2442
|
-
log(f"pre-warm failed: {e}")
|
|
2443
|
-
self._prewarm_ok = False
|
|
2444
|
-
|
|
2445
|
-
def _start_prewarm(self):
|
|
2446
|
-
self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
|
|
2447
|
-
self._warm_thread.start()
|
|
2448
|
-
|
|
2449
|
-
def grade(self, prompt):
|
|
2450
|
-
if self._warm_thread and self._prewarm_ok:
|
|
2451
|
-
self._warm_thread.join(timeout=8)
|
|
2452
|
-
elif self._warm_thread and not self._prewarm_ok:
|
|
2453
|
-
log("skipping prewarm join (last prewarm failed)")
|
|
2454
|
-
|
|
2455
|
-
with self._lock:
|
|
2456
|
-
proc = self._warm_proc
|
|
2457
|
-
ready_at = self._warm_ready_at
|
|
2458
|
-
self._warm_proc = None
|
|
2459
|
-
self._warm_ready_at = 0.0
|
|
2460
|
-
|
|
2461
|
-
WARM_TTL_SEC = int(os.environ.get("SYNKRO_DAEMON_WARM_TTL", "15"))
|
|
2462
|
-
# Decide if the warm process is usable for the race.
|
|
2463
|
-
warm_usable = bool(proc) and proc.poll() is None and not proc.stdin.closed
|
|
2464
|
-
if warm_usable and ready_at and (time.time() - ready_at) > WARM_TTL_SEC:
|
|
2465
|
-
self._kill_proc(proc)
|
|
2466
|
-
warm_usable = False
|
|
2467
|
-
if not warm_usable and proc:
|
|
2468
|
-
self._kill_proc(proc)
|
|
2469
|
-
proc = None
|
|
2470
|
-
|
|
2471
|
-
wall_limit = int(os.environ.get("SYNKRO_DAEMON_WALL_TIMEOUT", "20"))
|
|
2472
|
-
race_timeout = min(GRADE_TIMEOUT_SEC, wall_limit)
|
|
2473
|
-
|
|
2474
|
-
# Race a warm and a fresh cold process. Whichever returns a non-empty
|
|
2475
|
-
# response first wins; the other is killed. If we have no warm, just
|
|
2476
|
-
# run cold solo (no benefit to racing two cold spawns of the same age).
|
|
2477
|
-
cold_proc = self._make_proc() if warm_usable else proc or self._make_proc()
|
|
2478
|
-
warm_proc = proc if warm_usable else None
|
|
2479
|
-
|
|
2480
|
-
result_q = queue.Queue()
|
|
2481
|
-
stop_event = threading.Event()
|
|
2482
|
-
|
|
2483
|
-
def grade_worker(p, label):
|
|
2484
|
-
try:
|
|
2485
|
-
_send_msg(p, prompt, close_stdin=True)
|
|
2486
|
-
r = _read_response(p, timeout=race_timeout, stop_event=stop_event)
|
|
2487
|
-
if r:
|
|
2488
|
-
result_q.put((label, r))
|
|
2489
|
-
except Exception as e:
|
|
2490
|
-
log(f"grade {label} error: {e}")
|
|
2491
|
-
|
|
2492
|
-
threads = []
|
|
2493
|
-
if warm_proc is not None:
|
|
2494
|
-
t = threading.Thread(target=grade_worker, args=(warm_proc, "warm"), daemon=True)
|
|
2495
|
-
t.start()
|
|
2496
|
-
threads.append(t)
|
|
2497
|
-
t = threading.Thread(target=grade_worker, args=(cold_proc, "cold"), daemon=True)
|
|
2498
|
-
t.start()
|
|
2499
|
-
threads.append(t)
|
|
2500
|
-
|
|
2501
|
-
t0 = time.time()
|
|
2502
|
-
winner_label = None
|
|
2503
|
-
resp = ""
|
|
2504
|
-
try:
|
|
2505
|
-
deadline = time.time() + race_timeout + 2
|
|
2506
|
-
while time.time() < deadline:
|
|
2507
|
-
try:
|
|
2508
|
-
label, r = result_q.get(timeout=0.5)
|
|
2509
|
-
if r:
|
|
2510
|
-
resp = r
|
|
2511
|
-
winner_label = label
|
|
2512
|
-
break
|
|
2513
|
-
except queue.Empty:
|
|
2514
|
-
if all(not th.is_alive() for th in threads):
|
|
2515
|
-
break
|
|
2516
|
-
finally:
|
|
2517
|
-
stop_event.set()
|
|
2518
|
-
if warm_proc is not None:
|
|
2519
|
-
self._kill_proc(warm_proc)
|
|
2520
|
-
self._kill_proc(cold_proc)
|
|
2521
|
-
|
|
2522
|
-
elapsed = (time.time() - t0) * 1000
|
|
2523
|
-
self._total_grades += 1
|
|
2524
|
-
winner = winner_label or "none"
|
|
2525
|
-
log(f"grade #{self._total_grades} race={'warm+cold' if warm_proc else 'cold'} won={winner} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
|
|
2526
|
-
|
|
2527
|
-
self._start_prewarm()
|
|
2528
|
-
return resp
|
|
2529
|
-
|
|
2530
|
-
def shutdown(self):
|
|
2531
|
-
with self._lock:
|
|
2532
|
-
if self._warm_proc:
|
|
2533
|
-
self._kill_proc(self._warm_proc)
|
|
2534
|
-
self._warm_proc = None
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
def serve(primer):
|
|
2538
|
-
pid_fd = os.open(str(PID_FILE), os.O_RDWR | os.O_CREAT, 0o644)
|
|
2539
|
-
try:
|
|
2540
|
-
fcntl.flock(pid_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2541
|
-
except BlockingIOError:
|
|
2542
|
-
log("another daemon already holds the pid file; exiting")
|
|
2543
|
-
sys.exit(0)
|
|
2544
|
-
os.ftruncate(pid_fd, 0)
|
|
2545
|
-
os.write(pid_fd, f"{os.getpid()}\\n".encode())
|
|
2546
|
-
os.fsync(pid_fd)
|
|
2547
|
-
|
|
2548
|
-
grader = WarmGrader(primer)
|
|
2549
|
-
|
|
2550
|
-
if SOCK_PATH.exists():
|
|
2551
|
-
SOCK_PATH.unlink()
|
|
2552
|
-
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
2553
|
-
sock.bind(str(SOCK_PATH))
|
|
2554
|
-
sock.listen(8)
|
|
2555
|
-
os.chmod(SOCK_PATH, 0o600)
|
|
2556
|
-
|
|
2557
|
-
def cleanup(*_):
|
|
2558
|
-
try: SOCK_PATH.unlink()
|
|
2559
|
-
except Exception: pass
|
|
2560
|
-
try: PID_FILE.unlink()
|
|
2561
|
-
except Exception: pass
|
|
2562
|
-
grader.shutdown()
|
|
2563
|
-
sys.exit(0)
|
|
2564
|
-
signal.signal(signal.SIGTERM, cleanup)
|
|
2565
|
-
signal.signal(signal.SIGINT, cleanup)
|
|
2566
|
-
|
|
2567
|
-
log(f"daemon ready model={DEFAULT_MODEL} idle_shutdown={IDLE_SHUTDOWN_SEC}s sock={SOCK_PATH}")
|
|
2568
|
-
|
|
2569
|
-
last_activity = time.time()
|
|
2570
|
-
sock.settimeout(30)
|
|
2571
|
-
while True:
|
|
2572
|
-
try:
|
|
2573
|
-
conn, _ = sock.accept()
|
|
2574
|
-
last_activity = time.time()
|
|
2575
|
-
threading.Thread(target=_handle_conn, args=(conn, grader), daemon=True).start()
|
|
2576
|
-
except socket.timeout:
|
|
2577
|
-
if time.time() - last_activity > IDLE_SHUTDOWN_SEC:
|
|
2578
|
-
log(f"idle for {IDLE_SHUTDOWN_SEC}s, shutting down")
|
|
2579
|
-
cleanup()
|
|
2580
|
-
except Exception as e:
|
|
2581
|
-
log(f"accept error: {e}")
|
|
2582
|
-
time.sleep(0.1)
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
def _handle_conn(conn, grader):
|
|
2586
|
-
try:
|
|
2587
|
-
with conn:
|
|
2588
|
-
length_bytes = b""
|
|
2589
|
-
while len(length_bytes) < 8:
|
|
2590
|
-
chunk = conn.recv(8 - len(length_bytes))
|
|
2591
|
-
if not chunk: return
|
|
2592
|
-
length_bytes += chunk
|
|
2593
|
-
length = int.from_bytes(length_bytes, "big")
|
|
2594
|
-
if length <= 0 or length > MAX_PROMPT_BYTES:
|
|
2595
|
-
log(f"reject oversized prompt length={length}")
|
|
2596
|
-
return
|
|
2597
|
-
prompt = b""
|
|
2598
|
-
while len(prompt) < length:
|
|
2599
|
-
chunk = conn.recv(min(65536, length - len(prompt)))
|
|
2600
|
-
if not chunk: break
|
|
2601
|
-
prompt += chunk
|
|
2602
|
-
response = grader.grade(prompt.decode("utf-8", errors="replace"))
|
|
2603
|
-
resp_bytes = response.encode("utf-8")
|
|
2604
|
-
conn.sendall(len(resp_bytes).to_bytes(8, "big"))
|
|
2605
|
-
conn.sendall(resp_bytes)
|
|
2606
|
-
except Exception as e:
|
|
2607
|
-
log(f"conn error: {e}")
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
def daemon_running():
|
|
2611
|
-
if not SOCK_PATH.exists():
|
|
2612
|
-
return False
|
|
2613
|
-
try:
|
|
2614
|
-
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
2615
|
-
s.settimeout(0.5)
|
|
2616
|
-
s.connect(str(SOCK_PATH))
|
|
2617
|
-
s.close()
|
|
2618
|
-
return True
|
|
2619
|
-
except Exception:
|
|
2620
|
-
return False
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
def ensure_daemon_running(primer_path=None):
|
|
2624
|
-
if daemon_running():
|
|
2625
|
-
return True
|
|
2626
|
-
# Fetch primer from server (in-memory only \u2014 never written to disk).
|
|
2627
|
-
# Backwards compat: if a primer_path arg was passed (legacy hook scripts),
|
|
2628
|
-
# read from there as a fallback when API fetch fails.
|
|
2629
|
-
primer = fetch_primer(MODE)
|
|
2630
|
-
if not primer and primer_path:
|
|
2631
|
-
try:
|
|
2632
|
-
primer = Path(primer_path).read_text()
|
|
2633
|
-
except Exception:
|
|
2634
|
-
primer = ""
|
|
2635
|
-
pid = os.fork()
|
|
2636
|
-
if pid == 0:
|
|
2637
|
-
os.setsid()
|
|
2638
|
-
pid2 = os.fork()
|
|
2639
|
-
if pid2 == 0:
|
|
2640
|
-
null = os.open(os.devnull, os.O_RDWR)
|
|
2641
|
-
os.dup2(null, 0); os.dup2(null, 1); os.dup2(null, 2)
|
|
2642
|
-
serve(primer)
|
|
2643
|
-
else:
|
|
2644
|
-
os._exit(0)
|
|
2645
|
-
else:
|
|
2646
|
-
os.waitpid(pid, 0)
|
|
2647
|
-
for _ in range(150):
|
|
2648
|
-
time.sleep(0.1)
|
|
2649
|
-
if daemon_running():
|
|
2650
|
-
return True
|
|
2651
|
-
return False
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
def client_grade(prompt):
|
|
2655
|
-
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
2656
|
-
s.settimeout(GRADE_TIMEOUT_SEC + 5)
|
|
2657
|
-
s.connect(str(SOCK_PATH))
|
|
2658
|
-
pb = prompt.encode("utf-8")
|
|
2659
|
-
s.sendall(len(pb).to_bytes(8, "big"))
|
|
2660
|
-
s.sendall(pb)
|
|
2661
|
-
length_bytes = b""
|
|
2662
|
-
while len(length_bytes) < 8:
|
|
2663
|
-
chunk = s.recv(8 - len(length_bytes))
|
|
2664
|
-
if not chunk: return ""
|
|
2665
|
-
length_bytes += chunk
|
|
2666
|
-
length = int.from_bytes(length_bytes, "big")
|
|
2667
|
-
resp = b""
|
|
2668
|
-
while len(resp) < length:
|
|
2669
|
-
chunk = s.recv(min(65536, length - len(resp)))
|
|
2670
|
-
if not chunk: break
|
|
2671
|
-
resp += chunk
|
|
2672
|
-
s.close()
|
|
2673
|
-
return resp.decode("utf-8", errors="replace")
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
def main():
|
|
2677
|
-
global MODE, PID_FILE, SOCK_PATH, LOG_FILE
|
|
2678
|
-
args = list(sys.argv[1:])
|
|
2679
|
-
if len(args) >= 2 and args[0] == "--mode":
|
|
2680
|
-
candidate = args[1]
|
|
2681
|
-
if not ALLOWED_MODE_RE.match(candidate):
|
|
2682
|
-
print(f"invalid mode: {candidate}", file=sys.stderr); sys.exit(1)
|
|
2683
|
-
MODE = candidate
|
|
2684
|
-
PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
|
|
2685
|
-
args = args[2:]
|
|
2686
|
-
|
|
2687
|
-
if len(args) < 1:
|
|
2688
|
-
print("usage: grader_daemon.py [--mode <name>] {start|grade|stop|status}", file=sys.stderr)
|
|
2689
|
-
sys.exit(1)
|
|
2690
|
-
cmd = args[0]
|
|
2691
|
-
primer_path = args[1] if len(args) > 1 else None
|
|
2692
|
-
|
|
2693
|
-
if cmd == "start":
|
|
2694
|
-
if ensure_daemon_running(primer_path):
|
|
2695
|
-
print("daemon up")
|
|
2696
|
-
else:
|
|
2697
|
-
print("daemon failed to start", file=sys.stderr); sys.exit(1)
|
|
2698
|
-
elif cmd == "grade":
|
|
2699
|
-
ensure_daemon_running(primer_path)
|
|
2700
|
-
prompt = sys.stdin.read()
|
|
2701
|
-
try:
|
|
2702
|
-
print(client_grade(prompt), end="")
|
|
2703
|
-
except Exception as e:
|
|
2704
|
-
print(f"daemon-error: {e}", file=sys.stderr); sys.exit(2)
|
|
2705
|
-
elif cmd == "stop":
|
|
2706
|
-
if PID_FILE.exists():
|
|
2707
|
-
try:
|
|
2708
|
-
pid = int(PID_FILE.read_text().strip())
|
|
2709
|
-
os.kill(pid, signal.SIGTERM)
|
|
2710
|
-
print(f"sent SIGTERM to {pid}")
|
|
2711
|
-
except Exception: pass
|
|
2712
|
-
for p in (SOCK_PATH, PID_FILE):
|
|
2713
|
-
try: p.unlink()
|
|
2714
|
-
except Exception: pass
|
|
2715
|
-
elif cmd == "status":
|
|
2716
|
-
print("running" if daemon_running() else "stopped")
|
|
2717
|
-
else:
|
|
2718
|
-
print(f"unknown command: {cmd}", file=sys.stderr); sys.exit(1)
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
if __name__ == "__main__":
|
|
2722
|
-
main()
|
|
2723
|
-
`;
|
|
2724
|
-
}
|
|
2725
|
-
});
|
|
2726
|
-
|
|
2727
2191
|
// cli/auth/stub.ts
|
|
2728
2192
|
import { createServer } from "http";
|
|
2729
2193
|
import { writeFileSync as writeFileSync3, readFileSync as readFileSync3, existsSync as existsSync4, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2 } from "fs";
|
|
@@ -3186,50 +2650,15 @@ jobs:
|
|
|
3186
2650
|
|
|
3187
2651
|
// cli/installer/githubSetup.ts
|
|
3188
2652
|
import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2653
|
+
import { execSync as execSync2 } from "child_process";
|
|
3189
2654
|
import { join as join4 } from "path";
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
|
3197
|
-
}
|
|
3198
|
-
async function getRepoPublicKey(opts, owner, repo) {
|
|
3199
|
-
const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`;
|
|
3200
|
-
const resp = await fetch(url, {
|
|
3201
|
-
headers: {
|
|
3202
|
-
Authorization: `Bearer ${opts.token}`,
|
|
3203
|
-
Accept: "application/vnd.github+json",
|
|
3204
|
-
"X-GitHub-Api-Version": "2022-11-28"
|
|
3205
|
-
}
|
|
2655
|
+
function ghSecretSet(token, owner, repo, name, value) {
|
|
2656
|
+
execSync2(`gh secret set ${name} --repo ${owner}/${repo} --body -`, {
|
|
2657
|
+
input: value,
|
|
2658
|
+
env: { ...process.env, GH_TOKEN: token },
|
|
2659
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
2660
|
+
timeout: 3e4
|
|
3206
2661
|
});
|
|
3207
|
-
if (!resp.ok) {
|
|
3208
|
-
const text = await resp.text().catch(() => "");
|
|
3209
|
-
throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
|
|
3210
|
-
}
|
|
3211
|
-
return await resp.json();
|
|
3212
|
-
}
|
|
3213
|
-
async function putRepoSecret(opts, owner, repo, secretName, secretValue, publicKey) {
|
|
3214
|
-
const encryptedValue = await encryptSecret(publicKey.key, secretValue);
|
|
3215
|
-
const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
|
|
3216
|
-
const resp = await fetch(url, {
|
|
3217
|
-
method: "PUT",
|
|
3218
|
-
headers: {
|
|
3219
|
-
Authorization: `Bearer ${opts.token}`,
|
|
3220
|
-
Accept: "application/vnd.github+json",
|
|
3221
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
3222
|
-
"Content-Type": "application/json"
|
|
3223
|
-
},
|
|
3224
|
-
body: JSON.stringify({
|
|
3225
|
-
encrypted_value: encryptedValue,
|
|
3226
|
-
key_id: publicKey.key_id
|
|
3227
|
-
})
|
|
3228
|
-
});
|
|
3229
|
-
if (!resp.ok) {
|
|
3230
|
-
const text = await resp.text().catch(() => "");
|
|
3231
|
-
throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
|
|
3232
|
-
}
|
|
3233
2662
|
}
|
|
3234
2663
|
async function listAccessibleRepos(opts) {
|
|
3235
2664
|
const repos = [];
|
|
@@ -3257,11 +2686,15 @@ async function listAccessibleRepos(opts) {
|
|
|
3257
2686
|
return repos;
|
|
3258
2687
|
}
|
|
3259
2688
|
async function pushSecretsToRepo(opts, owner, repo, secrets) {
|
|
3260
|
-
|
|
2689
|
+
try {
|
|
2690
|
+
execSync2("gh --version", { stdio: "ignore", timeout: 5e3 });
|
|
2691
|
+
} catch {
|
|
2692
|
+
throw new Error("GitHub CLI (gh) not found. Install it: https://cli.github.com");
|
|
2693
|
+
}
|
|
3261
2694
|
if (secrets.claudeCodeOauthToken) {
|
|
3262
|
-
|
|
2695
|
+
ghSecretSet(opts.token, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken);
|
|
3263
2696
|
}
|
|
3264
|
-
|
|
2697
|
+
ghSecretSet(opts.token, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey);
|
|
3265
2698
|
}
|
|
3266
2699
|
function writeWorkflowFile(repoRootPath) {
|
|
3267
2700
|
const workflowDir = join4(repoRootPath, ".github", "workflows");
|
|
@@ -3294,12 +2727,12 @@ var init_githubSetup = __esm({
|
|
|
3294
2727
|
});
|
|
3295
2728
|
|
|
3296
2729
|
// cli/commands/repoConnect.ts
|
|
3297
|
-
import { execSync as
|
|
2730
|
+
import { execSync as execSync3 } from "child_process";
|
|
3298
2731
|
import { createServer as createServer2 } from "http";
|
|
3299
2732
|
import { createInterface } from "readline";
|
|
3300
2733
|
function detectGitRepo() {
|
|
3301
2734
|
try {
|
|
3302
|
-
const remoteUrl =
|
|
2735
|
+
const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
3303
2736
|
const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
3304
2737
|
const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
3305
2738
|
const match = sshMatch || httpMatch;
|
|
@@ -3515,7 +2948,7 @@ __export(setupGithub_exports, {
|
|
|
3515
2948
|
});
|
|
3516
2949
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
3517
2950
|
import { stdin as input, stdout as output } from "process";
|
|
3518
|
-
import { execSync as
|
|
2951
|
+
import { execSync as execSync4, spawn as nodeSpawn } from "child_process";
|
|
3519
2952
|
import { existsSync as existsSync6, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "fs";
|
|
3520
2953
|
import { homedir as homedir4, platform as platform2 } from "os";
|
|
3521
2954
|
import { join as join5 } from "path";
|
|
@@ -3725,7 +3158,7 @@ async function setupGithubCommand(opts = {}) {
|
|
|
3725
3158
|
}
|
|
3726
3159
|
} catch {
|
|
3727
3160
|
try {
|
|
3728
|
-
ghToken =
|
|
3161
|
+
ghToken = execSync4("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
3729
3162
|
} catch {
|
|
3730
3163
|
console.error("GitHub not connected. Run `synkro-cli setup-github` interactively to connect.");
|
|
3731
3164
|
return;
|
|
@@ -3759,7 +3192,7 @@ async function setupGithubCommand(opts = {}) {
|
|
|
3759
3192
|
}
|
|
3760
3193
|
console.log(" Validating token...");
|
|
3761
3194
|
try {
|
|
3762
|
-
const validateResult =
|
|
3195
|
+
const validateResult = execSync4(
|
|
3763
3196
|
'claude --print --output-format json "say ok"',
|
|
3764
3197
|
{ env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: claudeToken }, encoding: "utf-8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] }
|
|
3765
3198
|
);
|
|
@@ -3776,7 +3209,7 @@ async function setupGithubCommand(opts = {}) {
|
|
|
3776
3209
|
if (opts.nonInteractive) {
|
|
3777
3210
|
let currentFullName = null;
|
|
3778
3211
|
try {
|
|
3779
|
-
const remoteUrl =
|
|
3212
|
+
const remoteUrl = execSync4("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
3780
3213
|
const m = remoteUrl.match(/(?:github\.com)[:/](.+?)(?:\.git)?$/);
|
|
3781
3214
|
if (m) currentFullName = m[1];
|
|
3782
3215
|
} catch {
|
|
@@ -3870,16 +3303,700 @@ var init_setupGithub = __esm({
|
|
|
3870
3303
|
}
|
|
3871
3304
|
});
|
|
3872
3305
|
|
|
3306
|
+
// cli/installer/promptFetcher.ts
|
|
3307
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
|
|
3308
|
+
import { homedir as homedir5 } from "os";
|
|
3309
|
+
import { join as join6 } from "path";
|
|
3310
|
+
function readCache() {
|
|
3311
|
+
if (!existsSync7(CACHE_PATH)) return null;
|
|
3312
|
+
try {
|
|
3313
|
+
return JSON.parse(readFileSync5(CACHE_PATH, "utf-8"));
|
|
3314
|
+
} catch {
|
|
3315
|
+
return null;
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
function writeCache(entry) {
|
|
3319
|
+
mkdirSync5(join6(homedir5(), ".synkro", "prompts"), { recursive: true });
|
|
3320
|
+
writeFileSync5(CACHE_PATH, JSON.stringify(entry, null, 2), "utf-8");
|
|
3321
|
+
}
|
|
3322
|
+
function isCacheFresh(cache) {
|
|
3323
|
+
const ageMs = Date.now() - cache.fetched_at;
|
|
3324
|
+
const ttlMs = cache.ttl_hours * 60 * 60 * 1e3;
|
|
3325
|
+
return ageMs < ttlMs;
|
|
3326
|
+
}
|
|
3327
|
+
async function fetchJudgePrompts(opts) {
|
|
3328
|
+
if (!opts.forceRefresh) {
|
|
3329
|
+
const cache = readCache();
|
|
3330
|
+
if (cache && isCacheFresh(cache)) {
|
|
3331
|
+
return mapResponse(cache);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/cli/judge-prompts`;
|
|
3335
|
+
const resp = await fetch(url, {
|
|
3336
|
+
method: "GET",
|
|
3337
|
+
headers: {
|
|
3338
|
+
"Authorization": `Bearer ${opts.jwt}`,
|
|
3339
|
+
"User-Agent": "synkro-cli/1.0"
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
if (!resp.ok) {
|
|
3343
|
+
throw new Error(`Failed to fetch judge prompts: HTTP ${resp.status} ${resp.statusText}`);
|
|
3344
|
+
}
|
|
3345
|
+
const data = await resp.json();
|
|
3346
|
+
if (!data.edit_write_prompt) {
|
|
3347
|
+
throw new Error("Gateway returned empty edit_write_prompt");
|
|
3348
|
+
}
|
|
3349
|
+
writeCache({ ...data, fetched_at: Date.now() });
|
|
3350
|
+
return mapResponse(data);
|
|
3351
|
+
}
|
|
3352
|
+
function mapResponse(data) {
|
|
3353
|
+
return {
|
|
3354
|
+
editWritePrompt: data.edit_write_prompt,
|
|
3355
|
+
editAutofixAgentPrompt: data.edit_autofix_agent_prompt,
|
|
3356
|
+
editPrecheckAgentPrompt: data.edit_precheck_agent_prompt,
|
|
3357
|
+
graderPrimerBash: data.grader_primer_bash,
|
|
3358
|
+
graderPrimerEdit: data.grader_primer_edit,
|
|
3359
|
+
classificationPrompt: data.classification_prompt,
|
|
3360
|
+
intentClassifyPrompt: data.intent_classify_prompt,
|
|
3361
|
+
remediateIntentClassifyPrompt: data.remediate_intent_classify_prompt,
|
|
3362
|
+
version: data.version
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
var CACHE_PATH;
|
|
3366
|
+
var init_promptFetcher = __esm({
|
|
3367
|
+
"cli/installer/promptFetcher.ts"() {
|
|
3368
|
+
"use strict";
|
|
3369
|
+
CACHE_PATH = join6(homedir5(), ".synkro", "prompts", "judge-prompts.json");
|
|
3370
|
+
}
|
|
3371
|
+
});
|
|
3372
|
+
|
|
3373
|
+
// cli/storage/local.ts
|
|
3374
|
+
import Database from "better-sqlite3";
|
|
3375
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6 } from "fs";
|
|
3376
|
+
import { homedir as homedir6 } from "os";
|
|
3377
|
+
import { join as join7 } from "path";
|
|
3378
|
+
function getSetting(key) {
|
|
3379
|
+
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
|
3380
|
+
return row?.value ?? null;
|
|
3381
|
+
}
|
|
3382
|
+
function setSetting(key, value) {
|
|
3383
|
+
db.prepare(
|
|
3384
|
+
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
|
3385
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
3386
|
+
).run(key, value);
|
|
3387
|
+
}
|
|
3388
|
+
var SYNKRO_DIR2, DB_PATH, db;
|
|
3389
|
+
var init_local = __esm({
|
|
3390
|
+
"cli/storage/local.ts"() {
|
|
3391
|
+
"use strict";
|
|
3392
|
+
SYNKRO_DIR2 = join7(homedir6(), ".synkro");
|
|
3393
|
+
DB_PATH = join7(SYNKRO_DIR2, "sessions.db");
|
|
3394
|
+
if (!existsSync8(SYNKRO_DIR2)) {
|
|
3395
|
+
mkdirSync6(SYNKRO_DIR2, { recursive: true });
|
|
3396
|
+
}
|
|
3397
|
+
try {
|
|
3398
|
+
db = new Database(DB_PATH);
|
|
3399
|
+
} catch (err) {
|
|
3400
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3401
|
+
console.error(`Failed to initialize database at ${DB_PATH}: ${msg}`);
|
|
3402
|
+
console.error("Check that ~/.synkro/ is writable and disk is not full.");
|
|
3403
|
+
process.exit(1);
|
|
3404
|
+
}
|
|
3405
|
+
db.exec(`
|
|
3406
|
+
CREATE TABLE IF NOT EXISTS command_history (
|
|
3407
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3408
|
+
command TEXT NOT NULL,
|
|
3409
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
3410
|
+
);
|
|
3411
|
+
`);
|
|
3412
|
+
db.exec(`
|
|
3413
|
+
CREATE TABLE IF NOT EXISTS project_keys (
|
|
3414
|
+
slug TEXT PRIMARY KEY,
|
|
3415
|
+
project_id TEXT NOT NULL,
|
|
3416
|
+
project_name TEXT NOT NULL,
|
|
3417
|
+
api_key TEXT NOT NULL,
|
|
3418
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
3419
|
+
);
|
|
3420
|
+
`);
|
|
3421
|
+
db.exec(`
|
|
3422
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
3423
|
+
key TEXT PRIMARY KEY,
|
|
3424
|
+
value TEXT NOT NULL,
|
|
3425
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
3426
|
+
);
|
|
3427
|
+
`);
|
|
3428
|
+
process.on("exit", () => {
|
|
3429
|
+
db.close();
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
});
|
|
3433
|
+
|
|
3434
|
+
// cli/local-cc/settings.ts
|
|
3435
|
+
function getInferenceProvider() {
|
|
3436
|
+
const raw = getSetting(KEY);
|
|
3437
|
+
return raw === "local-cc" ? "local-cc" : "inngest";
|
|
3438
|
+
}
|
|
3439
|
+
function setInferenceProvider(value) {
|
|
3440
|
+
setSetting(KEY, value);
|
|
3441
|
+
}
|
|
3442
|
+
function isLocalCCEnabled() {
|
|
3443
|
+
return getInferenceProvider() === "local-cc";
|
|
3444
|
+
}
|
|
3445
|
+
var KEY;
|
|
3446
|
+
var init_settings = __esm({
|
|
3447
|
+
"cli/local-cc/settings.ts"() {
|
|
3448
|
+
"use strict";
|
|
3449
|
+
init_local();
|
|
3450
|
+
KEY = "inference_provider";
|
|
3451
|
+
}
|
|
3452
|
+
});
|
|
3453
|
+
|
|
3454
|
+
// cli/local-cc/channelSource.ts
|
|
3455
|
+
var CHANNEL_PLUGIN_SOURCE;
|
|
3456
|
+
var init_channelSource = __esm({
|
|
3457
|
+
"cli/local-cc/channelSource.ts"() {
|
|
3458
|
+
"use strict";
|
|
3459
|
+
CHANNEL_PLUGIN_SOURCE = `#!/usr/bin/env bun
|
|
3460
|
+
/**
|
|
3461
|
+
* Synkro local-CC channel plugin (auto-generated by \`synkro install\`).
|
|
3462
|
+
* DO NOT EDIT \u2014 your changes will be overwritten on next install.
|
|
3463
|
+
*/
|
|
3464
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3465
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3466
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3467
|
+
|
|
3468
|
+
const PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || '8929', 10);
|
|
3469
|
+
const HOSTNAME = '127.0.0.1';
|
|
3470
|
+
|
|
3471
|
+
const REQUEST_TIMEOUT_MS = parseInt(process.env.SYNKRO_CHANNEL_TIMEOUT_MS || '120000', 10);
|
|
3472
|
+
|
|
3473
|
+
interface PendingRequest {
|
|
3474
|
+
resolve: (result: string) => void;
|
|
3475
|
+
reject: (err: Error) => void;
|
|
3476
|
+
timer: ReturnType<typeof setTimeout>;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
const pending = new Map<string, PendingRequest>();
|
|
3480
|
+
let nextRequestId = 1;
|
|
3481
|
+
|
|
3482
|
+
const mcp = new Server(
|
|
3483
|
+
{ name: 'synkro-local', version: '0.1.0' },
|
|
3484
|
+
{
|
|
3485
|
+
capabilities: {
|
|
3486
|
+
experimental: { 'claude/channel': {} },
|
|
3487
|
+
tools: {},
|
|
3488
|
+
},
|
|
3489
|
+
instructions: [
|
|
3490
|
+
'Synkro local inference channel.',
|
|
3491
|
+
'Each <channel source="synkro-local" req_id="..." role="..."> event contains a',
|
|
3492
|
+
'self-contained instruction block followed by the payload to evaluate. Treat it',
|
|
3493
|
+
'as a fresh isolated request \u2014 IGNORE any prior conversation turns or context.',
|
|
3494
|
+
'Do not call Read, Edit, Write, Bash, or any other tool. Do exactly one thing:',
|
|
3495
|
+
'parse the request, produce the structured response described inside it, then',
|
|
3496
|
+
'call the \\\`reply\\\` tool exactly once with the same req_id and the response',
|
|
3497
|
+
'wrapped as the \\\`result\\\` argument (a string). Output no other text.',
|
|
3498
|
+
].join(' '),
|
|
3499
|
+
},
|
|
3500
|
+
);
|
|
3501
|
+
|
|
3502
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
3503
|
+
tools: [{
|
|
3504
|
+
name: 'reply',
|
|
3505
|
+
description: 'Return the response for a Synkro local-inference request',
|
|
3506
|
+
inputSchema: {
|
|
3507
|
+
type: 'object',
|
|
3508
|
+
properties: {
|
|
3509
|
+
req_id: { type: 'string', description: 'The req_id from the channel event being answered' },
|
|
3510
|
+
result: { type: 'string', description: 'The response text. For grading/classification roles, the JSON-tagged verdict the role requested.' },
|
|
3511
|
+
},
|
|
3512
|
+
required: ['req_id', 'result'],
|
|
3513
|
+
},
|
|
3514
|
+
}],
|
|
3515
|
+
}));
|
|
3516
|
+
|
|
3517
|
+
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
3518
|
+
if (req.params.name !== 'reply') {
|
|
3519
|
+
throw new Error('unknown tool: ' + req.params.name);
|
|
3520
|
+
}
|
|
3521
|
+
const args = req.params.arguments as { req_id?: string; result?: string };
|
|
3522
|
+
const reqId = String(args.req_id ?? '');
|
|
3523
|
+
const result = String(args.result ?? '');
|
|
3524
|
+
const p = pending.get(reqId);
|
|
3525
|
+
if (p) {
|
|
3526
|
+
clearTimeout(p.timer);
|
|
3527
|
+
pending.delete(reqId);
|
|
3528
|
+
p.resolve(result);
|
|
3529
|
+
return { content: [{ type: 'text', text: 'ok' }] };
|
|
3530
|
+
}
|
|
3531
|
+
return { content: [{ type: 'text', text: 'unknown req_id (likely already timed out)' }] };
|
|
3532
|
+
});
|
|
3533
|
+
|
|
3534
|
+
// Bind the listener BEFORE awaiting mcp.connect \u2014 Bun.serve is synchronous
|
|
3535
|
+
// and must run on the script's first tick, otherwise the stdio transport's
|
|
3536
|
+
// read loop can starve the serve setup.
|
|
3537
|
+
Bun.serve({
|
|
3538
|
+
port: PORT,
|
|
3539
|
+
hostname: HOSTNAME,
|
|
3540
|
+
idleTimeout: 0,
|
|
3541
|
+
async fetch(req) {
|
|
3542
|
+
const url = new URL(req.url);
|
|
3543
|
+
if (url.pathname === '/healthz') {
|
|
3544
|
+
return new Response(JSON.stringify({ ok: true, pending: pending.size }), {
|
|
3545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
if (req.method !== 'POST' || url.pathname !== '/submit') {
|
|
3549
|
+
return new Response('not found', { status: 404 });
|
|
3550
|
+
}
|
|
3551
|
+
let body: { role?: string; content?: string };
|
|
3552
|
+
try {
|
|
3553
|
+
body = await req.json() as typeof body;
|
|
3554
|
+
} catch {
|
|
3555
|
+
return new Response('invalid json', { status: 400 });
|
|
3556
|
+
}
|
|
3557
|
+
const role = String(body.role ?? '');
|
|
3558
|
+
const content = String(body.content ?? '');
|
|
3559
|
+
if (!role || !content) {
|
|
3560
|
+
return new Response('missing role/content', { status: 400 });
|
|
3561
|
+
}
|
|
3562
|
+
const reqId = 'r' + (nextRequestId++) + Date.now().toString(36);
|
|
3563
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
3564
|
+
const timer = setTimeout(() => {
|
|
3565
|
+
pending.delete(reqId);
|
|
3566
|
+
reject(new Error('timeout waiting for reply (' + REQUEST_TIMEOUT_MS + 'ms)'));
|
|
3567
|
+
}, REQUEST_TIMEOUT_MS);
|
|
3568
|
+
pending.set(reqId, { resolve, reject, timer });
|
|
3569
|
+
mcp.notification({
|
|
3570
|
+
method: 'notifications/claude/channel',
|
|
3571
|
+
params: {
|
|
3572
|
+
content,
|
|
3573
|
+
meta: { req_id: reqId, role },
|
|
3574
|
+
},
|
|
3575
|
+
}).catch(err => {
|
|
3576
|
+
clearTimeout(timer);
|
|
3577
|
+
pending.delete(reqId);
|
|
3578
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
3579
|
+
});
|
|
3580
|
+
}).catch(err => {
|
|
3581
|
+
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
|
|
3582
|
+
});
|
|
3583
|
+
if (typeof result === 'string' && result.startsWith('{"error":')) {
|
|
3584
|
+
return new Response(result, { status: 504, headers: { 'Content-Type': 'application/json' } });
|
|
3585
|
+
}
|
|
3586
|
+
return new Response(JSON.stringify({ result }), {
|
|
3587
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3588
|
+
});
|
|
3589
|
+
},
|
|
3590
|
+
});
|
|
3591
|
+
|
|
3592
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
3593
|
+
process.on('SIGINT', () => process.exit(0));
|
|
3594
|
+
|
|
3595
|
+
// MCP stdio handshake last. The transport's read loop keeps the process
|
|
3596
|
+
// alive; the TCP listener is already bound at this point so the CLI can
|
|
3597
|
+
// hit it as soon as Claude finishes its end of the handshake.
|
|
3598
|
+
await mcp.connect(new StdioServerTransport());
|
|
3599
|
+
`;
|
|
3600
|
+
}
|
|
3601
|
+
});
|
|
3602
|
+
|
|
3603
|
+
// cli/local-cc/install.ts
|
|
3604
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6, readFileSync as readFileSync6, chmodSync, copyFileSync, renameSync as renameSync3, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
|
|
3605
|
+
import { join as join8 } from "path";
|
|
3606
|
+
import { homedir as homedir7 } from "os";
|
|
3607
|
+
import { spawnSync } from "child_process";
|
|
3608
|
+
function writePluginFiles() {
|
|
3609
|
+
mkdirSync7(SESSION_DIR, { recursive: true });
|
|
3610
|
+
mkdirSync7(PLUGIN_SETTINGS_DIR, { recursive: true });
|
|
3611
|
+
writeFileSync6(PLUGIN_PATH, CHANNEL_PLUGIN_SOURCE, "utf-8");
|
|
3612
|
+
chmodSync(PLUGIN_PATH, 493);
|
|
3613
|
+
writeFileSync6(PLUGIN_PKG_PATH, PLUGIN_PACKAGE_JSON, "utf-8");
|
|
3614
|
+
writeFileSync6(
|
|
3615
|
+
PLUGIN_SETTINGS_PATH,
|
|
3616
|
+
JSON.stringify({
|
|
3617
|
+
fastMode: true,
|
|
3618
|
+
// Pre-approve the project-local synkro-local MCP server so claude doesn't
|
|
3619
|
+
// block on a consent prompt at startup. Lives in the PROJECT settings so
|
|
3620
|
+
// it's still picked up under --setting-sources project,local (which
|
|
3621
|
+
// skips user settings to avoid synkro-hook recursion in the grader).
|
|
3622
|
+
enabledMcpjsonServers: ["synkro-local"]
|
|
3623
|
+
}, null, 2) + "\n",
|
|
3624
|
+
"utf-8"
|
|
3625
|
+
);
|
|
3626
|
+
writeFileSync6(RUN_SCRIPT_PATH, RUN_SCRIPT_SOURCE, "utf-8");
|
|
3627
|
+
chmodSync(RUN_SCRIPT_PATH, 493);
|
|
3628
|
+
}
|
|
3629
|
+
function runBunInstall() {
|
|
3630
|
+
const r = spawnSync("bun", ["install", "--silent"], {
|
|
3631
|
+
cwd: SESSION_DIR,
|
|
3632
|
+
encoding: "utf-8",
|
|
3633
|
+
timeout: 12e4
|
|
3634
|
+
});
|
|
3635
|
+
if (r.status !== 0) {
|
|
3636
|
+
throw new LocalCCInstallError(
|
|
3637
|
+
`bun install failed in ${SESSION_DIR}: ${r.stderr || r.stdout || "unknown"}`
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
function safelyMutateClaudeJson(mutator) {
|
|
3642
|
+
if (!existsSync9(CLAUDE_JSON_PATH)) {
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
const originalText = readFileSync6(CLAUDE_JSON_PATH, "utf-8");
|
|
3646
|
+
let parsed;
|
|
3647
|
+
try {
|
|
3648
|
+
parsed = JSON.parse(originalText);
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
throw new LocalCCInstallError(
|
|
3651
|
+
`refusing to modify malformed ${CLAUDE_JSON_PATH}: ${err.message}. Please fix the JSON manually before retrying.`,
|
|
3652
|
+
err
|
|
3653
|
+
);
|
|
3654
|
+
}
|
|
3655
|
+
const originalKeys = new Set(Object.keys(parsed));
|
|
3656
|
+
const dirty = mutator(parsed);
|
|
3657
|
+
if (!dirty) return;
|
|
3658
|
+
for (const k of originalKeys) {
|
|
3659
|
+
if (!(k in parsed)) {
|
|
3660
|
+
throw new LocalCCInstallError(
|
|
3661
|
+
`refusing to write ${CLAUDE_JSON_PATH}: mutator dropped top-level key "${k}". This is a bug \u2014 please report.`
|
|
3662
|
+
);
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
const newText = JSON.stringify(parsed, null, 2) + "\n";
|
|
3666
|
+
try {
|
|
3667
|
+
JSON.parse(newText);
|
|
3668
|
+
} catch (err) {
|
|
3669
|
+
throw new LocalCCInstallError(
|
|
3670
|
+
`refusing to write ${CLAUDE_JSON_PATH}: serialized result is not valid JSON. This is a bug \u2014 please report.`,
|
|
3671
|
+
err
|
|
3672
|
+
);
|
|
3673
|
+
}
|
|
3674
|
+
copyFileSync(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
|
|
3675
|
+
const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
|
|
3676
|
+
try {
|
|
3677
|
+
writeFileSync6(tmpPath, newText, "utf-8");
|
|
3678
|
+
const fd = openSync(tmpPath, "r");
|
|
3679
|
+
try {
|
|
3680
|
+
fsyncSync(fd);
|
|
3681
|
+
} finally {
|
|
3682
|
+
closeSync(fd);
|
|
3683
|
+
}
|
|
3684
|
+
renameSync3(tmpPath, CLAUDE_JSON_PATH);
|
|
3685
|
+
} catch (err) {
|
|
3686
|
+
try {
|
|
3687
|
+
unlinkSync4(tmpPath);
|
|
3688
|
+
} catch {
|
|
3689
|
+
}
|
|
3690
|
+
try {
|
|
3691
|
+
copyFileSync(CLAUDE_JSON_BACKUP_PATH, CLAUDE_JSON_PATH);
|
|
3692
|
+
} catch {
|
|
3693
|
+
}
|
|
3694
|
+
throw new LocalCCInstallError(
|
|
3695
|
+
`failed to write ${CLAUDE_JSON_PATH}: ${err.message}. Backup at ${CLAUDE_JSON_BACKUP_PATH} preserves the prior state.`,
|
|
3696
|
+
err
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
function writeProjectMcpJson() {
|
|
3701
|
+
const mcp = {
|
|
3702
|
+
mcpServers: {
|
|
3703
|
+
[MCP_SERVER_NAME]: {
|
|
3704
|
+
command: "bun",
|
|
3705
|
+
args: [PLUGIN_PATH]
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
};
|
|
3709
|
+
writeFileSync6(PROJECT_MCP_PATH, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
|
|
3710
|
+
}
|
|
3711
|
+
function patchClaudeJson() {
|
|
3712
|
+
safelyMutateClaudeJson((parsed) => {
|
|
3713
|
+
let dirty = false;
|
|
3714
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === "object" && parsed.mcpServers[MCP_SERVER_NAME]) {
|
|
3715
|
+
delete parsed.mcpServers[MCP_SERVER_NAME];
|
|
3716
|
+
dirty = true;
|
|
3717
|
+
}
|
|
3718
|
+
if (!parsed.projects || typeof parsed.projects !== "object") {
|
|
3719
|
+
parsed.projects = {};
|
|
3720
|
+
}
|
|
3721
|
+
const projects = parsed.projects;
|
|
3722
|
+
const existing = projects[SESSION_DIR] && typeof projects[SESSION_DIR] === "object" ? projects[SESSION_DIR] : {};
|
|
3723
|
+
const wantEnabled = Array.from(/* @__PURE__ */ new Set([
|
|
3724
|
+
...existing.enabledMcpjsonServers ?? [],
|
|
3725
|
+
MCP_SERVER_NAME
|
|
3726
|
+
]));
|
|
3727
|
+
const next = {
|
|
3728
|
+
...existing,
|
|
3729
|
+
hasTrustDialogAccepted: true,
|
|
3730
|
+
hasCompletedProjectOnboarding: true,
|
|
3731
|
+
enabledMcpjsonServers: wantEnabled
|
|
3732
|
+
};
|
|
3733
|
+
if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
|
|
3734
|
+
projects[SESSION_DIR] = next;
|
|
3735
|
+
dirty = true;
|
|
3736
|
+
}
|
|
3737
|
+
return dirty;
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
function installLocalCC() {
|
|
3741
|
+
const bunCheck = spawnSync("bun", ["--version"], { encoding: "utf-8" });
|
|
3742
|
+
if (bunCheck.status !== 0) {
|
|
3743
|
+
throw new LocalCCInstallError("bun is required for the local-CC channel plugin. Install Bun (https://bun.sh) and retry.");
|
|
3744
|
+
}
|
|
3745
|
+
writePluginFiles();
|
|
3746
|
+
runBunInstall();
|
|
3747
|
+
writeProjectMcpJson();
|
|
3748
|
+
patchClaudeJson();
|
|
3749
|
+
return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
|
|
3750
|
+
}
|
|
3751
|
+
function uninstallLocalCC() {
|
|
3752
|
+
safelyMutateClaudeJson((parsed) => {
|
|
3753
|
+
let dirty = false;
|
|
3754
|
+
if (parsed.mcpServers && parsed.mcpServers[MCP_SERVER_NAME]) {
|
|
3755
|
+
delete parsed.mcpServers[MCP_SERVER_NAME];
|
|
3756
|
+
dirty = true;
|
|
3757
|
+
}
|
|
3758
|
+
if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[SESSION_DIR]) {
|
|
3759
|
+
delete parsed.projects[SESSION_DIR];
|
|
3760
|
+
dirty = true;
|
|
3761
|
+
}
|
|
3762
|
+
return dirty;
|
|
3763
|
+
});
|
|
3764
|
+
}
|
|
3765
|
+
var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, RUN_SCRIPT_SOURCE, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError;
|
|
3766
|
+
var init_install = __esm({
|
|
3767
|
+
"cli/local-cc/install.ts"() {
|
|
3768
|
+
"use strict";
|
|
3769
|
+
init_channelSource();
|
|
3770
|
+
CLAUDE_JSON_BACKUP_PATH = join8(homedir7(), ".claude.json.synkro-bak");
|
|
3771
|
+
SESSION_DIR = join8(homedir7(), ".synkro", "cc_sessions");
|
|
3772
|
+
PLUGIN_PATH = join8(SESSION_DIR, "synkro-channel.ts");
|
|
3773
|
+
PLUGIN_PKG_PATH = join8(SESSION_DIR, "package.json");
|
|
3774
|
+
PLUGIN_SETTINGS_DIR = join8(SESSION_DIR, ".claude");
|
|
3775
|
+
PLUGIN_SETTINGS_PATH = join8(PLUGIN_SETTINGS_DIR, "settings.json");
|
|
3776
|
+
PROJECT_MCP_PATH = join8(SESSION_DIR, ".mcp.json");
|
|
3777
|
+
CLAUDE_JSON_PATH = join8(homedir7(), ".claude.json");
|
|
3778
|
+
RUN_SCRIPT_PATH = join8(SESSION_DIR, "run-claude.sh");
|
|
3779
|
+
TMUX_SESSION_NAME = "synkro-local-cc";
|
|
3780
|
+
RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
|
|
3781
|
+
# Auto-generated by \`synkro install\`. Do not edit.
|
|
3782
|
+
set -uo pipefail
|
|
3783
|
+
|
|
3784
|
+
SESSION=${TMUX_SESSION_NAME}
|
|
3785
|
+
|
|
3786
|
+
# Kill any previous session so restarts come up clean.
|
|
3787
|
+
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
3788
|
+
|
|
3789
|
+
# Start claude inside a detached tmux session so it has a real pty.
|
|
3790
|
+
#
|
|
3791
|
+
# --setting-sources project,local: skip ~/.claude/settings.json so the
|
|
3792
|
+
# synkro PreToolUse/PostToolUse hooks installed there don't load. Without
|
|
3793
|
+
# this, the grader's own tool calls would re-trigger synkro grading,
|
|
3794
|
+
# causing recursion / deadlock with the same channel session.
|
|
3795
|
+
tmux new-session -d -s "$SESSION" \\
|
|
3796
|
+
"claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local"
|
|
3797
|
+
|
|
3798
|
+
# Block on the tmux session so pueue's task lifetime tracks claude's.
|
|
3799
|
+
while tmux has-session -t "$SESSION" 2>/dev/null; do
|
|
3800
|
+
sleep 5
|
|
3801
|
+
done
|
|
3802
|
+
`;
|
|
3803
|
+
MCP_SERVER_NAME = "synkro-local";
|
|
3804
|
+
PLUGIN_PACKAGE_JSON = JSON.stringify(
|
|
3805
|
+
{
|
|
3806
|
+
name: "synkro-local-channel",
|
|
3807
|
+
private: true,
|
|
3808
|
+
version: "0.1.0",
|
|
3809
|
+
type: "module",
|
|
3810
|
+
dependencies: {
|
|
3811
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
3812
|
+
}
|
|
3813
|
+
},
|
|
3814
|
+
null,
|
|
3815
|
+
2
|
|
3816
|
+
) + "\n";
|
|
3817
|
+
LocalCCInstallError = class extends Error {
|
|
3818
|
+
constructor(message, cause) {
|
|
3819
|
+
super(message);
|
|
3820
|
+
this.cause = cause;
|
|
3821
|
+
this.name = "LocalCCInstallError";
|
|
3822
|
+
}
|
|
3823
|
+
cause;
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
});
|
|
3827
|
+
|
|
3828
|
+
// cli/local-cc/pueue.ts
|
|
3829
|
+
import { execFileSync, spawnSync as spawnSync2 } from "child_process";
|
|
3830
|
+
import { homedir as homedir8 } from "os";
|
|
3831
|
+
import { join as join9 } from "path";
|
|
3832
|
+
import { connect } from "net";
|
|
3833
|
+
function pueueAvailable() {
|
|
3834
|
+
const r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
|
|
3835
|
+
if (r.status !== 0) {
|
|
3836
|
+
throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
function statusJson() {
|
|
3840
|
+
pueueAvailable();
|
|
3841
|
+
const r = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8" });
|
|
3842
|
+
if (r.status !== 0) {
|
|
3843
|
+
throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
|
|
3844
|
+
}
|
|
3845
|
+
try {
|
|
3846
|
+
return JSON.parse(r.stdout);
|
|
3847
|
+
} catch (err) {
|
|
3848
|
+
throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
function statusName(s) {
|
|
3852
|
+
if (typeof s === "string") return s;
|
|
3853
|
+
if (s && typeof s === "object") {
|
|
3854
|
+
if ("Running" in s) return "Running";
|
|
3855
|
+
if ("Done" in s) {
|
|
3856
|
+
const result = s.Done?.result;
|
|
3857
|
+
if (typeof result === "string") return `Done (${result})`;
|
|
3858
|
+
if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
|
|
3859
|
+
return "Done";
|
|
3860
|
+
}
|
|
3861
|
+
return Object.keys(s)[0] ?? "unknown";
|
|
3862
|
+
}
|
|
3863
|
+
return "unknown";
|
|
3864
|
+
}
|
|
3865
|
+
function findTask() {
|
|
3866
|
+
const data = statusJson();
|
|
3867
|
+
for (const [id, t] of Object.entries(data.tasks)) {
|
|
3868
|
+
if (t.label === TASK_LABEL) {
|
|
3869
|
+
return {
|
|
3870
|
+
id: Number(id),
|
|
3871
|
+
label: t.label,
|
|
3872
|
+
status: statusName(t.status),
|
|
3873
|
+
command: t.command,
|
|
3874
|
+
cwd: t.path
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
return null;
|
|
3879
|
+
}
|
|
3880
|
+
function startTask(opts = {}) {
|
|
3881
|
+
const cwd = opts.cwd ?? SESSION_DIR2;
|
|
3882
|
+
const existing = findTask();
|
|
3883
|
+
if (existing) {
|
|
3884
|
+
spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
|
|
3885
|
+
}
|
|
3886
|
+
const runScript = join9(cwd, "run-claude.sh");
|
|
3887
|
+
const args2 = [
|
|
3888
|
+
"add",
|
|
3889
|
+
"--label",
|
|
3890
|
+
TASK_LABEL,
|
|
3891
|
+
"--working-directory",
|
|
3892
|
+
cwd,
|
|
3893
|
+
"--",
|
|
3894
|
+
"bash",
|
|
3895
|
+
runScript
|
|
3896
|
+
];
|
|
3897
|
+
const r = spawnSync2("pueue", args2, { encoding: "utf-8" });
|
|
3898
|
+
if (r.status !== 0) {
|
|
3899
|
+
throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
|
|
3900
|
+
}
|
|
3901
|
+
const created = findTask();
|
|
3902
|
+
if (!created) {
|
|
3903
|
+
throw new PueueError(`pueue add succeeded but no task with label ${TASK_LABEL} found`);
|
|
3904
|
+
}
|
|
3905
|
+
return created;
|
|
3906
|
+
}
|
|
3907
|
+
function stopTask() {
|
|
3908
|
+
spawnSync2("tmux", ["kill-session", "-t", TMUX_SESSION], { encoding: "utf-8" });
|
|
3909
|
+
const t = findTask();
|
|
3910
|
+
if (!t) return;
|
|
3911
|
+
spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
|
|
3912
|
+
spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
|
|
3913
|
+
}
|
|
3914
|
+
function tailLogs(lines = 80) {
|
|
3915
|
+
const t = findTask();
|
|
3916
|
+
if (!t) return "(no synkro local-cc task)";
|
|
3917
|
+
const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
|
|
3918
|
+
return r.stdout || r.stderr || "(no output)";
|
|
3919
|
+
}
|
|
3920
|
+
function ensureRunning(opts = {}) {
|
|
3921
|
+
const t = findTask();
|
|
3922
|
+
if (t && t.status === "Running") return t;
|
|
3923
|
+
return startTask(opts);
|
|
3924
|
+
}
|
|
3925
|
+
function probePort(host, port, timeoutMs = 500) {
|
|
3926
|
+
return new Promise((resolve2) => {
|
|
3927
|
+
const sock = connect(port, host);
|
|
3928
|
+
const done = (ok) => {
|
|
3929
|
+
try {
|
|
3930
|
+
sock.destroy();
|
|
3931
|
+
} catch {
|
|
3932
|
+
}
|
|
3933
|
+
resolve2(ok);
|
|
3934
|
+
};
|
|
3935
|
+
sock.once("connect", () => done(true));
|
|
3936
|
+
sock.once("error", () => done(false));
|
|
3937
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
function tmuxKickEnter() {
|
|
3941
|
+
spawnSync2("tmux", ["send-keys", "-t", TMUX_SESSION, "Enter"], { encoding: "utf-8" });
|
|
3942
|
+
}
|
|
3943
|
+
async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1") {
|
|
3944
|
+
const deadline = Date.now() + timeoutMs;
|
|
3945
|
+
while (Date.now() < deadline) {
|
|
3946
|
+
if (await probePort(host, port)) return true;
|
|
3947
|
+
tmuxKickEnter();
|
|
3948
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
3949
|
+
}
|
|
3950
|
+
return probePort(host, port);
|
|
3951
|
+
}
|
|
3952
|
+
function assertPueueInstalled() {
|
|
3953
|
+
pueueAvailable();
|
|
3954
|
+
try {
|
|
3955
|
+
statusJson();
|
|
3956
|
+
} catch (err) {
|
|
3957
|
+
throw new PueueError(`pueue daemon not reachable: ${err.message}`);
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
function assertClaudeInstalled() {
|
|
3961
|
+
const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
|
|
3962
|
+
if (r.status !== 0) {
|
|
3963
|
+
throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
function assertTmuxInstalled() {
|
|
3967
|
+
const r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
|
|
3968
|
+
if (r.status !== 0) {
|
|
3969
|
+
throw new PueueError("tmux not found on PATH. Install tmux (brew install tmux) and retry.");
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, PueueError;
|
|
3973
|
+
var init_pueue = __esm({
|
|
3974
|
+
"cli/local-cc/pueue.ts"() {
|
|
3975
|
+
"use strict";
|
|
3976
|
+
TASK_LABEL = "synkro-local-cc";
|
|
3977
|
+
TMUX_SESSION = "synkro-local-cc";
|
|
3978
|
+
SESSION_DIR2 = join9(homedir8(), ".synkro", "cc_sessions");
|
|
3979
|
+
PueueError = class extends Error {
|
|
3980
|
+
constructor(message, cause) {
|
|
3981
|
+
super(message);
|
|
3982
|
+
this.cause = cause;
|
|
3983
|
+
this.name = "PueueError";
|
|
3984
|
+
}
|
|
3985
|
+
cause;
|
|
3986
|
+
};
|
|
3987
|
+
}
|
|
3988
|
+
});
|
|
3989
|
+
|
|
3873
3990
|
// cli/commands/install.ts
|
|
3874
3991
|
var install_exports = {};
|
|
3875
3992
|
__export(install_exports, {
|
|
3876
3993
|
installCommand: () => installCommand,
|
|
3877
3994
|
parseArgs: () => parseArgs
|
|
3878
3995
|
});
|
|
3879
|
-
import { existsSync as
|
|
3880
|
-
import { homedir as
|
|
3881
|
-
import { join as
|
|
3882
|
-
import { execSync as
|
|
3996
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
|
|
3997
|
+
import { homedir as homedir9 } from "os";
|
|
3998
|
+
import { join as join10 } from "path";
|
|
3999
|
+
import { execSync as execSync5 } from "child_process";
|
|
3883
4000
|
import { createInterface as createInterface3 } from "readline";
|
|
3884
4001
|
function sanitizeGatewayCandidate(raw) {
|
|
3885
4002
|
if (!raw) return void 0;
|
|
@@ -3915,43 +4032,33 @@ async function promptTranscriptConsent() {
|
|
|
3915
4032
|
});
|
|
3916
4033
|
}
|
|
3917
4034
|
function ensureSynkroDir() {
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
}
|
|
3923
|
-
function writeGraderDaemon() {
|
|
3924
|
-
writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
|
|
3925
|
-
chmodSync(GRADER_DAEMON_PATH, 493);
|
|
3926
|
-
for (const p of [GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH]) {
|
|
3927
|
-
try {
|
|
3928
|
-
__require("fs").unlinkSync(p);
|
|
3929
|
-
} catch {
|
|
3930
|
-
}
|
|
3931
|
-
}
|
|
4035
|
+
mkdirSync8(SYNKRO_DIR3, { recursive: true });
|
|
4036
|
+
mkdirSync8(HOOKS_DIR, { recursive: true });
|
|
4037
|
+
mkdirSync8(BIN_DIR, { recursive: true });
|
|
4038
|
+
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
3932
4039
|
}
|
|
3933
4040
|
function writeHookScripts() {
|
|
3934
|
-
const bashScriptPath =
|
|
3935
|
-
const bashFollowupScriptPath =
|
|
3936
|
-
const editCaptureScriptPath =
|
|
3937
|
-
const editPrecheckScriptPath =
|
|
3938
|
-
const stopSummaryScriptPath =
|
|
3939
|
-
const sessionStartScriptPath =
|
|
3940
|
-
const transcriptSyncScriptPath =
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
4041
|
+
const bashScriptPath = join10(HOOKS_DIR, "cc-bash-judge.sh");
|
|
4042
|
+
const bashFollowupScriptPath = join10(HOOKS_DIR, "cc-bash-followup.sh");
|
|
4043
|
+
const editCaptureScriptPath = join10(HOOKS_DIR, "cc-edit-capture.sh");
|
|
4044
|
+
const editPrecheckScriptPath = join10(HOOKS_DIR, "cc-edit-precheck.sh");
|
|
4045
|
+
const stopSummaryScriptPath = join10(HOOKS_DIR, "cc-stop-summary.sh");
|
|
4046
|
+
const sessionStartScriptPath = join10(HOOKS_DIR, "cc-session-start.sh");
|
|
4047
|
+
const transcriptSyncScriptPath = join10(HOOKS_DIR, "cc-transcript-sync.sh");
|
|
4048
|
+
writeFileSync7(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
|
|
4049
|
+
writeFileSync7(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
|
|
4050
|
+
writeFileSync7(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
4051
|
+
writeFileSync7(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
4052
|
+
writeFileSync7(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
|
|
4053
|
+
writeFileSync7(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
|
|
4054
|
+
writeFileSync7(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
|
|
4055
|
+
chmodSync2(bashScriptPath, 493);
|
|
4056
|
+
chmodSync2(bashFollowupScriptPath, 493);
|
|
4057
|
+
chmodSync2(editCaptureScriptPath, 493);
|
|
4058
|
+
chmodSync2(editPrecheckScriptPath, 493);
|
|
4059
|
+
chmodSync2(stopSummaryScriptPath, 493);
|
|
4060
|
+
chmodSync2(sessionStartScriptPath, 493);
|
|
4061
|
+
chmodSync2(transcriptSyncScriptPath, 493);
|
|
3955
4062
|
return {
|
|
3956
4063
|
bashScript: bashScriptPath,
|
|
3957
4064
|
bashFollowupScript: bashFollowupScriptPath,
|
|
@@ -3969,14 +4076,20 @@ function sanitizeConfigValue(raw, maxLen = 256) {
|
|
|
3969
4076
|
function shellQuoteSingle(value) {
|
|
3970
4077
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
3971
4078
|
}
|
|
4079
|
+
function resolveSynkroBundle() {
|
|
4080
|
+
const scriptPath = process.argv[1];
|
|
4081
|
+
if (scriptPath && existsSync10(scriptPath)) return scriptPath;
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
3972
4084
|
function writeConfigEnv(opts) {
|
|
3973
|
-
const credsPath =
|
|
4085
|
+
const credsPath = join10(SYNKRO_DIR3, "credentials.json");
|
|
3974
4086
|
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
3975
4087
|
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
3976
4088
|
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
3977
4089
|
const safeEmail = sanitizeConfigValue(opts.email);
|
|
3978
4090
|
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
3979
4091
|
const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
|
|
4092
|
+
const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
|
|
3980
4093
|
const lines = [
|
|
3981
4094
|
"# Synkro CLI config (managed by synkro install)",
|
|
3982
4095
|
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
@@ -3985,8 +4098,9 @@ function writeConfigEnv(opts) {
|
|
|
3985
4098
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
3986
4099
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
3987
4100
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
3988
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.
|
|
4101
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.0")}`
|
|
3989
4102
|
];
|
|
4103
|
+
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
3990
4104
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
3991
4105
|
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
3992
4106
|
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
@@ -3994,17 +4108,17 @@ function writeConfigEnv(opts) {
|
|
|
3994
4108
|
lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
|
|
3995
4109
|
}
|
|
3996
4110
|
lines.push("");
|
|
3997
|
-
|
|
3998
|
-
|
|
4111
|
+
writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
|
|
4112
|
+
chmodSync2(CONFIG_PATH2, 384);
|
|
3999
4113
|
}
|
|
4000
4114
|
function collectLocalMetadata() {
|
|
4001
4115
|
const meta = { platform: process.platform };
|
|
4002
4116
|
try {
|
|
4003
|
-
meta.display_name =
|
|
4117
|
+
meta.display_name = execSync5("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
4004
4118
|
} catch {
|
|
4005
4119
|
}
|
|
4006
4120
|
try {
|
|
4007
|
-
const remote =
|
|
4121
|
+
const remote = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
4008
4122
|
const sshMatch = remote.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
4009
4123
|
const httpMatch = remote.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
4010
4124
|
const m = sshMatch || httpMatch;
|
|
@@ -4012,34 +4126,34 @@ function collectLocalMetadata() {
|
|
|
4012
4126
|
} catch {
|
|
4013
4127
|
}
|
|
4014
4128
|
try {
|
|
4015
|
-
meta.cc_version =
|
|
4129
|
+
meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
|
|
4016
4130
|
} catch {
|
|
4017
4131
|
}
|
|
4018
|
-
const claudeDir =
|
|
4132
|
+
const claudeDir = join10(homedir9(), ".claude");
|
|
4019
4133
|
try {
|
|
4020
|
-
const settings = JSON.parse(
|
|
4134
|
+
const settings = JSON.parse(readFileSync7(join10(claudeDir, "settings.json"), "utf-8"));
|
|
4021
4135
|
const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
|
|
4022
4136
|
if (plugins.length) meta.enabled_plugins = plugins;
|
|
4023
4137
|
if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
|
|
4024
4138
|
} catch {
|
|
4025
4139
|
}
|
|
4026
4140
|
try {
|
|
4027
|
-
const mcpCache = JSON.parse(
|
|
4141
|
+
const mcpCache = JSON.parse(readFileSync7(join10(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
|
|
4028
4142
|
const mcpNames = Object.keys(mcpCache);
|
|
4029
4143
|
if (mcpNames.length) meta.mcp_servers = mcpNames;
|
|
4030
4144
|
} catch {
|
|
4031
4145
|
}
|
|
4032
4146
|
try {
|
|
4033
|
-
const mcpList =
|
|
4147
|
+
const mcpList = execSync5("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
|
|
4034
4148
|
const connected = mcpList.split("\n").filter((l) => l.includes("Connected")).map((l) => l.split(":")[0].trim()).filter(Boolean);
|
|
4035
4149
|
if (connected.length) meta.mcp_servers_connected = connected;
|
|
4036
4150
|
} catch {
|
|
4037
4151
|
}
|
|
4038
4152
|
try {
|
|
4039
|
-
const sessionsDir =
|
|
4153
|
+
const sessionsDir = join10(claudeDir, "sessions");
|
|
4040
4154
|
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
|
|
4041
4155
|
for (const f of files) {
|
|
4042
|
-
const s = JSON.parse(
|
|
4156
|
+
const s = JSON.parse(readFileSync7(join10(sessionsDir, f), "utf-8"));
|
|
4043
4157
|
if (s.version) {
|
|
4044
4158
|
meta.cc_version = meta.cc_version || s.version;
|
|
4045
4159
|
break;
|
|
@@ -4094,19 +4208,19 @@ function assertGatewayAllowed(gatewayUrl) {
|
|
|
4094
4208
|
}
|
|
4095
4209
|
function isAlreadyInstalled() {
|
|
4096
4210
|
const requiredScripts = [
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4211
|
+
join10(HOOKS_DIR, "cc-bash-judge.sh"),
|
|
4212
|
+
join10(HOOKS_DIR, "cc-bash-followup.sh"),
|
|
4213
|
+
join10(HOOKS_DIR, "cc-edit-precheck.sh"),
|
|
4214
|
+
join10(HOOKS_DIR, "cc-edit-capture.sh"),
|
|
4215
|
+
join10(HOOKS_DIR, "cc-stop-summary.sh"),
|
|
4216
|
+
join10(HOOKS_DIR, "cc-session-start.sh")
|
|
4103
4217
|
];
|
|
4104
|
-
if (!requiredScripts.every((p) =>
|
|
4105
|
-
if (!
|
|
4106
|
-
const settingsPath =
|
|
4107
|
-
if (!
|
|
4218
|
+
if (!requiredScripts.every((p) => existsSync10(p))) return false;
|
|
4219
|
+
if (!existsSync10(CONFIG_PATH2)) return false;
|
|
4220
|
+
const settingsPath = join10(homedir9(), ".claude", "settings.json");
|
|
4221
|
+
if (!existsSync10(settingsPath)) return false;
|
|
4108
4222
|
try {
|
|
4109
|
-
const settings = JSON.parse(
|
|
4223
|
+
const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
4110
4224
|
const hooks = settings?.hooks;
|
|
4111
4225
|
if (!hooks || typeof hooks !== "object") return false;
|
|
4112
4226
|
const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
|
|
@@ -4213,23 +4327,31 @@ async function installCommand(opts = {}) {
|
|
|
4213
4327
|
console.log(` ${scripts.sessionStartScript}`);
|
|
4214
4328
|
console.log(` ${scripts.transcriptSyncScript}
|
|
4215
4329
|
`);
|
|
4216
|
-
writeGraderDaemon();
|
|
4217
4330
|
for (const mode of ["edit", "bash"]) {
|
|
4218
|
-
const pidFile =
|
|
4331
|
+
const pidFile = join10(SYNKRO_DIR3, "daemon", mode, "daemon.pid");
|
|
4219
4332
|
try {
|
|
4220
|
-
const pid = parseInt(
|
|
4333
|
+
const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
|
|
4221
4334
|
if (pid > 0) {
|
|
4222
4335
|
process.kill(pid, "SIGTERM");
|
|
4223
|
-
console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
|
|
4336
|
+
console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
|
|
4224
4337
|
}
|
|
4225
4338
|
} catch {
|
|
4226
4339
|
}
|
|
4227
4340
|
}
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4341
|
+
if (isLocalCCEnabled()) {
|
|
4342
|
+
try {
|
|
4343
|
+
assertClaudeInstalled();
|
|
4344
|
+
assertPueueInstalled();
|
|
4345
|
+
const r = installLocalCC();
|
|
4346
|
+
console.log(`Installed local-CC channel plugin at ${r.pluginPath}`);
|
|
4347
|
+
const t = ensureRunning();
|
|
4348
|
+
console.log(`Local-CC pueue task: id=${t.id} status=${t.status}
|
|
4232
4349
|
`);
|
|
4350
|
+
} catch (err) {
|
|
4351
|
+
console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
|
|
4352
|
+
console.warn(" Run `synkro local-cc enable` after fixing the issue.\n");
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4233
4355
|
let transcriptConsent = true;
|
|
4234
4356
|
if (process.stdin.isTTY) {
|
|
4235
4357
|
transcriptConsent = await promptTranscriptConsent();
|
|
@@ -4295,10 +4417,19 @@ async function installCommand(opts = {}) {
|
|
|
4295
4417
|
} catch {
|
|
4296
4418
|
}
|
|
4297
4419
|
const profile = await fetchUserProfile(gatewayUrl, token);
|
|
4298
|
-
|
|
4420
|
+
const synkroBundle = resolveSynkroBundle();
|
|
4421
|
+
writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent });
|
|
4299
4422
|
console.log(`Wrote config to ${CONFIG_PATH2}`);
|
|
4300
|
-
console.log(` inference: ${profile.inference} (server-side grading)
|
|
4301
|
-
`);
|
|
4423
|
+
console.log(` inference: ${profile.inference} (server-side grading)`);
|
|
4424
|
+
if (synkroBundle) console.log(` SYNKRO_CLI_BIN=${synkroBundle}`);
|
|
4425
|
+
else console.warn(" \u26A0 Could not resolve synkro bundle path; hooks will fall back to PATH lookup of `synkro`.");
|
|
4426
|
+
try {
|
|
4427
|
+
const prompts = await fetchJudgePrompts({ gatewayUrl, jwt: token });
|
|
4428
|
+
console.log(` prompts: ${prompts.version} (cached)`);
|
|
4429
|
+
} catch (err) {
|
|
4430
|
+
console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
|
|
4431
|
+
}
|
|
4432
|
+
console.log();
|
|
4302
4433
|
if (transcriptConsent) {
|
|
4303
4434
|
try {
|
|
4304
4435
|
const repo = detectGitRepo2();
|
|
@@ -4333,7 +4464,7 @@ async function installCommand(opts = {}) {
|
|
|
4333
4464
|
}
|
|
4334
4465
|
function detectGitRepo2() {
|
|
4335
4466
|
try {
|
|
4336
|
-
const remoteUrl =
|
|
4467
|
+
const remoteUrl = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
4337
4468
|
const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
4338
4469
|
const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
4339
4470
|
const match = sshMatch || httpMatch;
|
|
@@ -4345,17 +4476,17 @@ function detectGitRepo2() {
|
|
|
4345
4476
|
function getClaudeProjectsFolder() {
|
|
4346
4477
|
const cwd = process.cwd();
|
|
4347
4478
|
const sanitized = "-" + cwd.replace(/\//g, "-");
|
|
4348
|
-
const projectsDir =
|
|
4349
|
-
return
|
|
4479
|
+
const projectsDir = join10(homedir9(), ".claude", "projects", sanitized);
|
|
4480
|
+
return existsSync10(projectsDir) ? projectsDir : null;
|
|
4350
4481
|
}
|
|
4351
4482
|
function extractSessionInsights(projectsDir) {
|
|
4352
4483
|
const insights = [];
|
|
4353
4484
|
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
4354
4485
|
for (const file of files) {
|
|
4355
4486
|
const sessionId = file.replace(".jsonl", "");
|
|
4356
|
-
const filePath =
|
|
4487
|
+
const filePath = join10(projectsDir, file);
|
|
4357
4488
|
try {
|
|
4358
|
-
const content =
|
|
4489
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4359
4490
|
const lines = content.split("\n").filter(Boolean);
|
|
4360
4491
|
for (let i = 0; i < lines.length; i++) {
|
|
4361
4492
|
try {
|
|
@@ -4431,7 +4562,7 @@ function extractTextContent(content) {
|
|
|
4431
4562
|
return "";
|
|
4432
4563
|
}
|
|
4433
4564
|
function parseTranscriptFile(filePath) {
|
|
4434
|
-
const content =
|
|
4565
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4435
4566
|
const lines = content.split("\n").filter(Boolean);
|
|
4436
4567
|
const messages = [];
|
|
4437
4568
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -4482,7 +4613,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
|
4482
4613
|
const sessions = [];
|
|
4483
4614
|
for (const file of batch) {
|
|
4484
4615
|
const sessionId = file.replace(".jsonl", "");
|
|
4485
|
-
const filePath =
|
|
4616
|
+
const filePath = join10(projectsDir, file);
|
|
4486
4617
|
try {
|
|
4487
4618
|
const allMessages = parseTranscriptFile(filePath);
|
|
4488
4619
|
const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
|
|
@@ -4511,38 +4642,38 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
|
4511
4642
|
}
|
|
4512
4643
|
for (const file of batch) {
|
|
4513
4644
|
const sessionId = file.replace(".jsonl", "");
|
|
4514
|
-
const filePath =
|
|
4645
|
+
const filePath = join10(projectsDir, file);
|
|
4515
4646
|
try {
|
|
4516
|
-
const content =
|
|
4647
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4517
4648
|
const lineCount = content.split("\n").filter(Boolean).length;
|
|
4518
|
-
|
|
4649
|
+
writeFileSync7(join10(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
|
|
4519
4650
|
} catch {
|
|
4520
4651
|
}
|
|
4521
4652
|
}
|
|
4522
4653
|
}
|
|
4523
4654
|
return { sessions: totalSessions, messages: totalMessages };
|
|
4524
4655
|
}
|
|
4525
|
-
var
|
|
4526
|
-
var
|
|
4656
|
+
var SYNKRO_DIR3, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, OFFSETS_DIR;
|
|
4657
|
+
var init_install2 = __esm({
|
|
4527
4658
|
"cli/commands/install.ts"() {
|
|
4528
4659
|
"use strict";
|
|
4529
4660
|
init_agentDetect();
|
|
4530
4661
|
init_ccHookConfig();
|
|
4531
4662
|
init_mcpConfig();
|
|
4532
4663
|
init_hookScripts();
|
|
4533
|
-
init_graderDaemon();
|
|
4534
4664
|
init_stub();
|
|
4535
4665
|
init_repoConnect();
|
|
4536
4666
|
init_projects();
|
|
4537
4667
|
init_setupGithub();
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4668
|
+
init_promptFetcher();
|
|
4669
|
+
init_settings();
|
|
4670
|
+
init_install();
|
|
4671
|
+
init_pueue();
|
|
4672
|
+
SYNKRO_DIR3 = join10(homedir9(), ".synkro");
|
|
4673
|
+
HOOKS_DIR = join10(SYNKRO_DIR3, "hooks");
|
|
4674
|
+
BIN_DIR = join10(SYNKRO_DIR3, "bin");
|
|
4675
|
+
CONFIG_PATH2 = join10(SYNKRO_DIR3, "config.env");
|
|
4676
|
+
OFFSETS_DIR = join10(SYNKRO_DIR3, ".transcript-offsets");
|
|
4546
4677
|
}
|
|
4547
4678
|
});
|
|
4548
4679
|
|
|
@@ -4618,13 +4749,13 @@ var status_exports = {};
|
|
|
4618
4749
|
__export(status_exports, {
|
|
4619
4750
|
statusCommand: () => statusCommand
|
|
4620
4751
|
});
|
|
4621
|
-
import { existsSync as
|
|
4622
|
-
import { homedir as
|
|
4623
|
-
import { join as
|
|
4752
|
+
import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
|
|
4753
|
+
import { homedir as homedir10 } from "os";
|
|
4754
|
+
import { join as join11 } from "path";
|
|
4624
4755
|
function readConfigEnv() {
|
|
4625
|
-
if (!
|
|
4756
|
+
if (!existsSync11(CONFIG_PATH3)) return {};
|
|
4626
4757
|
const out = {};
|
|
4627
|
-
const raw =
|
|
4758
|
+
const raw = readFileSync8(CONFIG_PATH3, "utf-8");
|
|
4628
4759
|
for (const line of raw.split("\n")) {
|
|
4629
4760
|
const trimmed = line.trim();
|
|
4630
4761
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -4697,19 +4828,19 @@ async function statusCommand() {
|
|
|
4697
4828
|
}
|
|
4698
4829
|
}
|
|
4699
4830
|
console.log();
|
|
4700
|
-
const bashScript =
|
|
4701
|
-
const bashFollowupScript =
|
|
4702
|
-
const editPrecheckScript =
|
|
4703
|
-
const editCaptureScript =
|
|
4704
|
-
const stopSummaryScript =
|
|
4705
|
-
const sessionStartScript =
|
|
4831
|
+
const bashScript = join11(SYNKRO_DIR4, "hooks", "cc-bash-judge.sh");
|
|
4832
|
+
const bashFollowupScript = join11(SYNKRO_DIR4, "hooks", "cc-bash-followup.sh");
|
|
4833
|
+
const editPrecheckScript = join11(SYNKRO_DIR4, "hooks", "cc-edit-precheck.sh");
|
|
4834
|
+
const editCaptureScript = join11(SYNKRO_DIR4, "hooks", "cc-edit-capture.sh");
|
|
4835
|
+
const stopSummaryScript = join11(SYNKRO_DIR4, "hooks", "cc-stop-summary.sh");
|
|
4836
|
+
const sessionStartScript = join11(SYNKRO_DIR4, "hooks", "cc-session-start.sh");
|
|
4706
4837
|
console.log("Hook scripts:");
|
|
4707
|
-
console.log(` ${
|
|
4708
|
-
console.log(` ${
|
|
4709
|
-
console.log(` ${
|
|
4710
|
-
console.log(` ${
|
|
4711
|
-
console.log(` ${
|
|
4712
|
-
console.log(` ${
|
|
4838
|
+
console.log(` ${existsSync11(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
|
|
4839
|
+
console.log(` ${existsSync11(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
|
|
4840
|
+
console.log(` ${existsSync11(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
|
|
4841
|
+
console.log(` ${existsSync11(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
|
|
4842
|
+
console.log(` ${existsSync11(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
|
|
4843
|
+
console.log(` ${existsSync11(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
|
|
4713
4844
|
console.log();
|
|
4714
4845
|
const mcp = inspectMcpConfig();
|
|
4715
4846
|
console.log("Guardrails MCP server (Claude Code):");
|
|
@@ -4721,7 +4852,7 @@ async function statusCommand() {
|
|
|
4721
4852
|
console.log(` expected at ${mcp.configPath} \u2192 mcpServers.synkro-guardrails`);
|
|
4722
4853
|
}
|
|
4723
4854
|
}
|
|
4724
|
-
var
|
|
4855
|
+
var SYNKRO_DIR4, CONFIG_PATH3;
|
|
4725
4856
|
var init_status = __esm({
|
|
4726
4857
|
"cli/commands/status.ts"() {
|
|
4727
4858
|
"use strict";
|
|
@@ -4729,8 +4860,8 @@ var init_status = __esm({
|
|
|
4729
4860
|
init_agentDetect();
|
|
4730
4861
|
init_ccHookConfig();
|
|
4731
4862
|
init_mcpConfig();
|
|
4732
|
-
|
|
4733
|
-
CONFIG_PATH3 =
|
|
4863
|
+
SYNKRO_DIR4 = join11(homedir10(), ".synkro");
|
|
4864
|
+
CONFIG_PATH3 = join11(SYNKRO_DIR4, "config.env");
|
|
4734
4865
|
}
|
|
4735
4866
|
});
|
|
4736
4867
|
|
|
@@ -4819,13 +4950,13 @@ var config_exports = {};
|
|
|
4819
4950
|
__export(config_exports, {
|
|
4820
4951
|
configCommand: () => configCommand
|
|
4821
4952
|
});
|
|
4822
|
-
import { readFileSync as
|
|
4823
|
-
import { join as
|
|
4824
|
-
import { homedir as
|
|
4953
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync8, existsSync as existsSync12 } from "fs";
|
|
4954
|
+
import { join as join12 } from "path";
|
|
4955
|
+
import { homedir as homedir11 } from "os";
|
|
4825
4956
|
function readConfigEnv2() {
|
|
4826
|
-
if (!
|
|
4957
|
+
if (!existsSync12(CONFIG_PATH4)) return {};
|
|
4827
4958
|
const out = {};
|
|
4828
|
-
for (const line of
|
|
4959
|
+
for (const line of readFileSync9(CONFIG_PATH4, "utf-8").split("\n")) {
|
|
4829
4960
|
const t = line.trim();
|
|
4830
4961
|
if (!t || t.startsWith("#")) continue;
|
|
4831
4962
|
const eq = t.indexOf("=");
|
|
@@ -4834,11 +4965,11 @@ function readConfigEnv2() {
|
|
|
4834
4965
|
return out;
|
|
4835
4966
|
}
|
|
4836
4967
|
function updateConfigValue(key, value) {
|
|
4837
|
-
if (!
|
|
4968
|
+
if (!existsSync12(CONFIG_PATH4)) {
|
|
4838
4969
|
console.error("No config found. Run `synkro install` first.");
|
|
4839
4970
|
process.exit(1);
|
|
4840
4971
|
}
|
|
4841
|
-
const lines =
|
|
4972
|
+
const lines = readFileSync9(CONFIG_PATH4, "utf-8").split("\n");
|
|
4842
4973
|
const pattern = new RegExp(`^${key}=`);
|
|
4843
4974
|
let found = false;
|
|
4844
4975
|
const updated = lines.map((line) => {
|
|
@@ -4849,7 +4980,7 @@ function updateConfigValue(key, value) {
|
|
|
4849
4980
|
return line;
|
|
4850
4981
|
});
|
|
4851
4982
|
if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
|
|
4852
|
-
|
|
4983
|
+
writeFileSync8(CONFIG_PATH4, updated.join("\n"), "utf-8");
|
|
4853
4984
|
}
|
|
4854
4985
|
async function configCommand(args2) {
|
|
4855
4986
|
if (args2.length === 0) {
|
|
@@ -4900,13 +5031,13 @@ To change: synkro config --inference fast|standard`);
|
|
|
4900
5031
|
updateConfigValue("SYNKRO_INFERENCE", inferenceValue);
|
|
4901
5032
|
console.log(`\u2713 Inference set to '${inferenceValue}'.`);
|
|
4902
5033
|
}
|
|
4903
|
-
var
|
|
5034
|
+
var SYNKRO_DIR5, CONFIG_PATH4;
|
|
4904
5035
|
var init_config = __esm({
|
|
4905
5036
|
"cli/commands/config.ts"() {
|
|
4906
5037
|
"use strict";
|
|
4907
5038
|
init_stub();
|
|
4908
|
-
|
|
4909
|
-
CONFIG_PATH4 =
|
|
5039
|
+
SYNKRO_DIR5 = join12(homedir11(), ".synkro");
|
|
5040
|
+
CONFIG_PATH4 = join12(SYNKRO_DIR5, "config.env");
|
|
4910
5041
|
}
|
|
4911
5042
|
});
|
|
4912
5043
|
|
|
@@ -4915,9 +5046,9 @@ var scanPr_exports = {};
|
|
|
4915
5046
|
__export(scanPr_exports, {
|
|
4916
5047
|
scanPrCommand: () => scanPrCommand
|
|
4917
5048
|
});
|
|
4918
|
-
import { execSync as
|
|
4919
|
-
import { readFileSync as
|
|
4920
|
-
import { join as
|
|
5049
|
+
import { execSync as execSync6, spawn } from "child_process";
|
|
5050
|
+
import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
|
|
5051
|
+
import { join as join13 } from "path";
|
|
4921
5052
|
function parseMatchSpec(condition) {
|
|
4922
5053
|
if (!condition.startsWith("match_spec:")) return null;
|
|
4923
5054
|
try {
|
|
@@ -5023,7 +5154,7 @@ function shouldSkipFile(filename) {
|
|
|
5023
5154
|
return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
|
|
5024
5155
|
}
|
|
5025
5156
|
function ghJson(args2) {
|
|
5026
|
-
const out =
|
|
5157
|
+
const out = execSync6(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
|
|
5027
5158
|
encoding: "utf-8",
|
|
5028
5159
|
maxBuffer: 16 * 1024 * 1024
|
|
5029
5160
|
});
|
|
@@ -5321,7 +5452,7 @@ function postPrReview(repo, prNumber, sha, review, skipLineReview = false) {
|
|
|
5321
5452
|
${review.summary}
|
|
5322
5453
|
|
|
5323
5454
|
` + review.comments.map((c) => `**${c.path}:${c.line}** \u2014 ${c.body}`).join("\n\n");
|
|
5324
|
-
|
|
5455
|
+
execSync6(`gh api -X POST /repos/${repo}/issues/${prNumber}/comments --input -`, {
|
|
5325
5456
|
encoding: "utf-8",
|
|
5326
5457
|
input: JSON.stringify({ body }),
|
|
5327
5458
|
stdio: ["pipe", "ignore", "pipe"]
|
|
@@ -5346,7 +5477,7 @@ ${review.summary}`,
|
|
|
5346
5477
|
comments: review.comments
|
|
5347
5478
|
});
|
|
5348
5479
|
try {
|
|
5349
|
-
|
|
5480
|
+
execSync6(`gh api -X POST /repos/${repo}/pulls/${prNumber}/reviews --input -`, {
|
|
5350
5481
|
encoding: "utf-8",
|
|
5351
5482
|
input: body,
|
|
5352
5483
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -5381,7 +5512,7 @@ function postCheckRun(repo, sha, conclusion, findings) {
|
|
|
5381
5512
|
}
|
|
5382
5513
|
});
|
|
5383
5514
|
try {
|
|
5384
|
-
|
|
5515
|
+
execSync6(`gh api -X POST /repos/${repo}/check-runs --input -`, {
|
|
5385
5516
|
encoding: "utf-8",
|
|
5386
5517
|
input: body,
|
|
5387
5518
|
stdio: ["pipe", "ignore", "pipe"]
|
|
@@ -5396,10 +5527,10 @@ function shouldFail(findings, threshold) {
|
|
|
5396
5527
|
return findings.some((f) => order.indexOf(f.severity) >= thresholdIdx);
|
|
5397
5528
|
}
|
|
5398
5529
|
function readRepoDeps() {
|
|
5399
|
-
const pkgPath =
|
|
5400
|
-
if (!
|
|
5530
|
+
const pkgPath = join13(process.cwd(), "package.json");
|
|
5531
|
+
if (!existsSync13(pkgPath)) return {};
|
|
5401
5532
|
try {
|
|
5402
|
-
const pkg = JSON.parse(
|
|
5533
|
+
const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
|
|
5403
5534
|
return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
5404
5535
|
} catch {
|
|
5405
5536
|
return {};
|
|
@@ -5407,7 +5538,7 @@ function readRepoDeps() {
|
|
|
5407
5538
|
}
|
|
5408
5539
|
function getFullFileContent(filename) {
|
|
5409
5540
|
try {
|
|
5410
|
-
return
|
|
5541
|
+
return execSync6(`git show HEAD:${filename}`, { encoding: "utf-8", maxBuffer: 128 * 1024 });
|
|
5411
5542
|
} catch {
|
|
5412
5543
|
return null;
|
|
5413
5544
|
}
|
|
@@ -5652,7 +5783,7 @@ async function updateCommand() {
|
|
|
5652
5783
|
var init_update = __esm({
|
|
5653
5784
|
"cli/commands/update.ts"() {
|
|
5654
5785
|
"use strict";
|
|
5655
|
-
|
|
5786
|
+
init_install2();
|
|
5656
5787
|
}
|
|
5657
5788
|
});
|
|
5658
5789
|
|
|
@@ -5661,12 +5792,24 @@ var disconnect_exports = {};
|
|
|
5661
5792
|
__export(disconnect_exports, {
|
|
5662
5793
|
disconnectCommand: () => disconnectCommand
|
|
5663
5794
|
});
|
|
5664
|
-
import { existsSync as
|
|
5665
|
-
import { homedir as
|
|
5666
|
-
import { join as
|
|
5795
|
+
import { existsSync as existsSync14, rmSync } from "fs";
|
|
5796
|
+
import { homedir as homedir12 } from "os";
|
|
5797
|
+
import { join as join14 } from "path";
|
|
5798
|
+
function tearDownLocalCC() {
|
|
5799
|
+
let hadTask = false;
|
|
5800
|
+
try {
|
|
5801
|
+
hadTask = !!findTask();
|
|
5802
|
+
stopTask();
|
|
5803
|
+
} catch {
|
|
5804
|
+
}
|
|
5805
|
+
console.log(`${hadTask ? "\u2713" : "\xB7"} local-cc runtime: ${hadTask ? "stopped pueue task + tmux session" : "no live task"}`);
|
|
5806
|
+
uninstallLocalCC();
|
|
5807
|
+
console.log("\u2713 local-cc config: cleaned ~/.claude.json entries");
|
|
5808
|
+
}
|
|
5667
5809
|
function disconnectCommand(args2 = []) {
|
|
5668
5810
|
const purge = args2.includes("--purge");
|
|
5669
5811
|
console.log("Synkro disconnect starting...\n");
|
|
5812
|
+
tearDownLocalCC();
|
|
5670
5813
|
const agents = detectAgents();
|
|
5671
5814
|
let sawClaudeCode = false;
|
|
5672
5815
|
for (const agent of agents) {
|
|
@@ -5681,25 +5824,27 @@ function disconnectCommand(args2 = []) {
|
|
|
5681
5824
|
console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
|
|
5682
5825
|
}
|
|
5683
5826
|
if (purge) {
|
|
5684
|
-
if (
|
|
5685
|
-
rmSync(
|
|
5686
|
-
console.log(`\u2713 Removed ${
|
|
5827
|
+
if (existsSync14(SYNKRO_DIR6)) {
|
|
5828
|
+
rmSync(SYNKRO_DIR6, { recursive: true, force: true });
|
|
5829
|
+
console.log(`\u2713 Removed ${SYNKRO_DIR6}`);
|
|
5687
5830
|
} else {
|
|
5688
|
-
console.log(`\xB7 ${
|
|
5831
|
+
console.log(`\xB7 ${SYNKRO_DIR6} already gone, nothing to remove`);
|
|
5689
5832
|
}
|
|
5690
|
-
} else if (
|
|
5691
|
-
console.log(`Config preserved at ${
|
|
5833
|
+
} else if (existsSync14(SYNKRO_DIR6)) {
|
|
5834
|
+
console.log(`Config preserved at ${SYNKRO_DIR6}. Run with --purge to remove.`);
|
|
5692
5835
|
}
|
|
5693
5836
|
console.log("\nSynkro disconnected.");
|
|
5694
5837
|
}
|
|
5695
|
-
var
|
|
5838
|
+
var SYNKRO_DIR6;
|
|
5696
5839
|
var init_disconnect = __esm({
|
|
5697
5840
|
"cli/commands/disconnect.ts"() {
|
|
5698
5841
|
"use strict";
|
|
5699
5842
|
init_agentDetect();
|
|
5700
5843
|
init_ccHookConfig();
|
|
5701
5844
|
init_mcpConfig();
|
|
5702
|
-
|
|
5845
|
+
init_pueue();
|
|
5846
|
+
init_install();
|
|
5847
|
+
SYNKRO_DIR6 = join14(homedir12(), ".synkro");
|
|
5703
5848
|
}
|
|
5704
5849
|
});
|
|
5705
5850
|
|
|
@@ -5736,20 +5881,682 @@ var init_reinstall = __esm({
|
|
|
5736
5881
|
"cli/commands/reinstall.ts"() {
|
|
5737
5882
|
"use strict";
|
|
5738
5883
|
init_disconnect();
|
|
5884
|
+
init_install2();
|
|
5885
|
+
}
|
|
5886
|
+
});
|
|
5887
|
+
|
|
5888
|
+
// cli/local-cc/turnLog.ts
|
|
5889
|
+
import { appendFileSync, existsSync as existsSync15, mkdirSync as mkdirSync9, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync, watchFile, unwatchFile } from "fs";
|
|
5890
|
+
import { dirname as dirname5, join as join15 } from "path";
|
|
5891
|
+
import { homedir as homedir13 } from "os";
|
|
5892
|
+
function truncate(s, max = PREVIEW_MAX) {
|
|
5893
|
+
if (s.length <= max) return s;
|
|
5894
|
+
return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
|
|
5895
|
+
}
|
|
5896
|
+
function extractSeverity(result) {
|
|
5897
|
+
const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
|
|
5898
|
+
if (!m) return void 0;
|
|
5899
|
+
try {
|
|
5900
|
+
const obj = JSON.parse(m[1]);
|
|
5901
|
+
if (obj.severity) return String(obj.severity);
|
|
5902
|
+
if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
|
|
5903
|
+
if (obj.type) return String(obj.type);
|
|
5904
|
+
if (obj.verdict) return String(obj.verdict);
|
|
5905
|
+
} catch {
|
|
5906
|
+
}
|
|
5907
|
+
return void 0;
|
|
5908
|
+
}
|
|
5909
|
+
function appendTurn(args2) {
|
|
5910
|
+
try {
|
|
5911
|
+
mkdirSync9(dirname5(TURN_LOG_PATH), { recursive: true });
|
|
5912
|
+
const entry = {
|
|
5913
|
+
ts: new Date(args2.startedAt).toISOString(),
|
|
5914
|
+
role: args2.role,
|
|
5915
|
+
duration_ms: Date.now() - args2.startedAt,
|
|
5916
|
+
status: args2.status,
|
|
5917
|
+
request_preview: truncate(args2.request),
|
|
5918
|
+
response_preview: args2.result ? truncate(args2.result) : "",
|
|
5919
|
+
severity: args2.result ? extractSeverity(args2.result) : void 0,
|
|
5920
|
+
error: args2.error
|
|
5921
|
+
};
|
|
5922
|
+
appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
|
|
5923
|
+
} catch {
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
function readRecentTurns(n = 20) {
|
|
5927
|
+
if (!existsSync15(TURN_LOG_PATH)) return [];
|
|
5928
|
+
try {
|
|
5929
|
+
const size = statSync(TURN_LOG_PATH).size;
|
|
5930
|
+
if (size === 0) return [];
|
|
5931
|
+
const text = readFileSync11(TURN_LOG_PATH, "utf-8");
|
|
5932
|
+
const lines = text.split("\n").filter(Boolean);
|
|
5933
|
+
const lastN = lines.slice(-n).reverse();
|
|
5934
|
+
return lastN.map((line) => {
|
|
5935
|
+
try {
|
|
5936
|
+
return JSON.parse(line);
|
|
5937
|
+
} catch {
|
|
5938
|
+
return null;
|
|
5939
|
+
}
|
|
5940
|
+
}).filter((x) => x !== null);
|
|
5941
|
+
} catch {
|
|
5942
|
+
return [];
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
function followTurns(onEntry) {
|
|
5946
|
+
try {
|
|
5947
|
+
mkdirSync9(dirname5(TURN_LOG_PATH), { recursive: true });
|
|
5948
|
+
if (!existsSync15(TURN_LOG_PATH)) {
|
|
5949
|
+
appendFileSync(TURN_LOG_PATH, "", "utf-8");
|
|
5950
|
+
}
|
|
5951
|
+
} catch {
|
|
5952
|
+
}
|
|
5953
|
+
let lastSize = (() => {
|
|
5954
|
+
try {
|
|
5955
|
+
return statSync(TURN_LOG_PATH).size;
|
|
5956
|
+
} catch {
|
|
5957
|
+
return 0;
|
|
5958
|
+
}
|
|
5959
|
+
})();
|
|
5960
|
+
let pendingPartial = "";
|
|
5961
|
+
const drainNewBytes = (from, to) => {
|
|
5962
|
+
if (to <= from) return;
|
|
5963
|
+
let fd = null;
|
|
5964
|
+
try {
|
|
5965
|
+
fd = openSync2(TURN_LOG_PATH, "r");
|
|
5966
|
+
const len = to - from;
|
|
5967
|
+
const buf = Buffer.alloc(len);
|
|
5968
|
+
readSync(fd, buf, 0, len, from);
|
|
5969
|
+
const text = pendingPartial + buf.toString("utf-8");
|
|
5970
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
5971
|
+
if (lastNewline === -1) {
|
|
5972
|
+
pendingPartial = text;
|
|
5973
|
+
return;
|
|
5974
|
+
}
|
|
5975
|
+
const complete = text.slice(0, lastNewline);
|
|
5976
|
+
pendingPartial = text.slice(lastNewline + 1);
|
|
5977
|
+
for (const line of complete.split("\n")) {
|
|
5978
|
+
if (!line) continue;
|
|
5979
|
+
try {
|
|
5980
|
+
onEntry(JSON.parse(line));
|
|
5981
|
+
} catch {
|
|
5982
|
+
}
|
|
5983
|
+
}
|
|
5984
|
+
} catch {
|
|
5985
|
+
} finally {
|
|
5986
|
+
if (fd !== null) {
|
|
5987
|
+
try {
|
|
5988
|
+
closeSync2(fd);
|
|
5989
|
+
} catch {
|
|
5990
|
+
}
|
|
5991
|
+
}
|
|
5992
|
+
}
|
|
5993
|
+
};
|
|
5994
|
+
watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
|
|
5995
|
+
if (curr.size < lastSize) {
|
|
5996
|
+
lastSize = 0;
|
|
5997
|
+
pendingPartial = "";
|
|
5998
|
+
}
|
|
5999
|
+
if (curr.size > lastSize) {
|
|
6000
|
+
drainNewBytes(lastSize, curr.size);
|
|
6001
|
+
lastSize = curr.size;
|
|
6002
|
+
}
|
|
6003
|
+
});
|
|
6004
|
+
return () => unwatchFile(TURN_LOG_PATH);
|
|
6005
|
+
}
|
|
6006
|
+
var TURN_LOG_PATH, PREVIEW_MAX;
|
|
6007
|
+
var init_turnLog = __esm({
|
|
6008
|
+
"cli/local-cc/turnLog.ts"() {
|
|
6009
|
+
"use strict";
|
|
6010
|
+
TURN_LOG_PATH = join15(homedir13(), ".synkro", "cc_sessions", "turns.log");
|
|
6011
|
+
PREVIEW_MAX = 400;
|
|
6012
|
+
}
|
|
6013
|
+
});
|
|
6014
|
+
|
|
6015
|
+
// cli/local-cc/prompts.ts
|
|
6016
|
+
import { existsSync as existsSync16, readFileSync as readFileSync12 } from "fs";
|
|
6017
|
+
import { homedir as homedir14 } from "os";
|
|
6018
|
+
import { join as join16 } from "path";
|
|
6019
|
+
function loadCachedPrompts() {
|
|
6020
|
+
if (_cached) return _cached;
|
|
6021
|
+
if (!existsSync16(CACHE_PATH2)) {
|
|
6022
|
+
throw new Error("Prompts cache not found. Run `synkro install` or `synkro update` first.");
|
|
6023
|
+
}
|
|
6024
|
+
try {
|
|
6025
|
+
_cached = JSON.parse(readFileSync12(CACHE_PATH2, "utf-8"));
|
|
6026
|
+
return _cached;
|
|
6027
|
+
} catch {
|
|
6028
|
+
throw new Error("Prompts cache is corrupted. Run `synkro update` to refresh.");
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
function getPrimer(role) {
|
|
6032
|
+
const cache = loadCachedPrompts();
|
|
6033
|
+
const primer = role === "grade-edit" ? cache.grader_primer_edit : cache.grader_primer_bash;
|
|
6034
|
+
if (!primer) {
|
|
6035
|
+
throw new Error(`No cached primer for role "${role}". Run \`synkro update\` to refresh prompts.`);
|
|
6036
|
+
}
|
|
6037
|
+
return primer;
|
|
6038
|
+
}
|
|
6039
|
+
function buildChannelContent(role, payload) {
|
|
6040
|
+
return `${getPrimer(role)}
|
|
6041
|
+
|
|
6042
|
+
---
|
|
6043
|
+
PAYLOAD (the input to evaluate):
|
|
6044
|
+
|
|
6045
|
+
${payload}`;
|
|
6046
|
+
}
|
|
6047
|
+
var CACHE_PATH2, _cached;
|
|
6048
|
+
var init_prompts = __esm({
|
|
6049
|
+
"cli/local-cc/prompts.ts"() {
|
|
6050
|
+
"use strict";
|
|
6051
|
+
CACHE_PATH2 = join16(homedir14(), ".synkro", "prompts", "judge-prompts.json");
|
|
6052
|
+
_cached = null;
|
|
6053
|
+
}
|
|
6054
|
+
});
|
|
6055
|
+
|
|
6056
|
+
// cli/local-cc/client.ts
|
|
6057
|
+
import { request as httpRequest } from "http";
|
|
6058
|
+
import { connect as connect2 } from "net";
|
|
6059
|
+
async function submitToChannel(role, payload, opts = {}) {
|
|
6060
|
+
const content = buildChannelContent(role, payload);
|
|
6061
|
+
const body = JSON.stringify({ role, content });
|
|
6062
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
6063
|
+
const startedAt = Date.now();
|
|
6064
|
+
try {
|
|
6065
|
+
const result = await new Promise((resolve2, reject) => {
|
|
6066
|
+
const req = httpRequest({
|
|
6067
|
+
host: CHANNEL_HOST,
|
|
6068
|
+
port: CHANNEL_PORT,
|
|
6069
|
+
method: "POST",
|
|
6070
|
+
path: "/submit",
|
|
6071
|
+
headers: {
|
|
6072
|
+
"Content-Type": "application/json",
|
|
6073
|
+
"Content-Length": Buffer.byteLength(body)
|
|
6074
|
+
},
|
|
6075
|
+
timeout: timeoutMs
|
|
6076
|
+
}, (res) => {
|
|
6077
|
+
const chunks = [];
|
|
6078
|
+
res.on("data", (c) => chunks.push(c));
|
|
6079
|
+
res.on("end", () => {
|
|
6080
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
6081
|
+
if (res.statusCode !== 200) {
|
|
6082
|
+
reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
|
|
6083
|
+
return;
|
|
6084
|
+
}
|
|
6085
|
+
try {
|
|
6086
|
+
const parsed = JSON.parse(text);
|
|
6087
|
+
if (parsed.error) {
|
|
6088
|
+
reject(new LocalCCError(parsed.error));
|
|
6089
|
+
return;
|
|
6090
|
+
}
|
|
6091
|
+
resolve2(String(parsed.result ?? ""));
|
|
6092
|
+
} catch (err) {
|
|
6093
|
+
reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
|
|
6094
|
+
}
|
|
6095
|
+
});
|
|
6096
|
+
});
|
|
6097
|
+
req.on("timeout", () => {
|
|
6098
|
+
req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
|
|
6099
|
+
});
|
|
6100
|
+
req.on("error", (err) => {
|
|
6101
|
+
const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
|
|
6102
|
+
reject(new LocalCCError(msg, err));
|
|
6103
|
+
});
|
|
6104
|
+
req.write(body);
|
|
6105
|
+
req.end();
|
|
6106
|
+
});
|
|
6107
|
+
appendTurn({ startedAt, role, request: payload, result, status: "ok" });
|
|
6108
|
+
return result;
|
|
6109
|
+
} catch (err) {
|
|
6110
|
+
const message = err.message ?? String(err);
|
|
6111
|
+
const status = /timed out/i.test(message) ? "timeout" : "error";
|
|
6112
|
+
appendTurn({ startedAt, role, request: payload, status, error: message });
|
|
6113
|
+
throw err;
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
function isChannelAvailable(timeoutMs = 500) {
|
|
6117
|
+
return new Promise((resolve2) => {
|
|
6118
|
+
const sock = connect2(CHANNEL_PORT, CHANNEL_HOST);
|
|
6119
|
+
const done = (ok) => {
|
|
6120
|
+
try {
|
|
6121
|
+
sock.destroy();
|
|
6122
|
+
} catch {
|
|
6123
|
+
}
|
|
6124
|
+
resolve2(ok);
|
|
6125
|
+
};
|
|
6126
|
+
sock.once("connect", () => done(true));
|
|
6127
|
+
sock.once("error", () => done(false));
|
|
6128
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
6129
|
+
});
|
|
6130
|
+
}
|
|
6131
|
+
var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
|
|
6132
|
+
var init_client = __esm({
|
|
6133
|
+
"cli/local-cc/client.ts"() {
|
|
6134
|
+
"use strict";
|
|
6135
|
+
init_prompts();
|
|
6136
|
+
init_turnLog();
|
|
6137
|
+
CHANNEL_HOST = "127.0.0.1";
|
|
6138
|
+
CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
|
|
6139
|
+
DEFAULT_TIMEOUT_MS = 9e4;
|
|
6140
|
+
LocalCCError = class extends Error {
|
|
6141
|
+
constructor(message, cause) {
|
|
6142
|
+
super(message);
|
|
6143
|
+
this.cause = cause;
|
|
6144
|
+
this.name = "LocalCCError";
|
|
6145
|
+
}
|
|
6146
|
+
cause;
|
|
6147
|
+
};
|
|
6148
|
+
}
|
|
6149
|
+
});
|
|
6150
|
+
|
|
6151
|
+
// cli/commands/localCc.ts
|
|
6152
|
+
var localCc_exports = {};
|
|
6153
|
+
__export(localCc_exports, {
|
|
6154
|
+
localCcCommand: () => localCcCommand
|
|
6155
|
+
});
|
|
6156
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
6157
|
+
import { homedir as homedir15 } from "os";
|
|
6158
|
+
import { join as join17 } from "path";
|
|
6159
|
+
function printHelp() {
|
|
6160
|
+
const dbPath = join17(homedir15(), ".synkro", "sessions.db");
|
|
6161
|
+
console.log(`synkro local-cc \u2014 manage the local Claude Code inference session
|
|
6162
|
+
|
|
6163
|
+
OVERVIEW
|
|
6164
|
+
Routes Synkro's grading and intent-classification work through a long-running
|
|
6165
|
+
Claude Code session on this machine instead of remote Inngest+Gemini.
|
|
6166
|
+
|
|
6167
|
+
When enabled, three call sites switch over:
|
|
6168
|
+
\u2022 security grading on edit/bash hooks
|
|
6169
|
+
\u2022 intent classification (agent input \u2192 structured intent)
|
|
6170
|
+
\u2022 remediate intent classification
|
|
6171
|
+
|
|
6172
|
+
The session is hosted in a detached tmux session managed by a pueue task.
|
|
6173
|
+
The CLI talks to it via Claude Code's channels API: a Bun MCP plugin that
|
|
6174
|
+
pushes events into the session and receives Claude's responses through a
|
|
6175
|
+
\`reply\` MCP tool.
|
|
6176
|
+
|
|
6177
|
+
USAGE
|
|
6178
|
+
synkro local-cc <subcommand> [args]
|
|
6179
|
+
|
|
6180
|
+
SUBCOMMANDS
|
|
6181
|
+
enable Install plugin, start pueue task, flip toggle to local-cc
|
|
6182
|
+
disable Flip toggle back to inngest (pueue task left running)
|
|
6183
|
+
status Show provider toggle, pueue task state, channel reachability
|
|
6184
|
+
start Idempotently bring up the pueue task + wait for the channel
|
|
6185
|
+
stop Kill the tmux session and remove the pueue task
|
|
6186
|
+
restart stop, then start
|
|
6187
|
+
install Regenerate ~/.synkro/cc_sessions/ files (plugin, runner, settings)
|
|
6188
|
+
logs [N] [--raw] [--live]
|
|
6189
|
+
Show the last N (default 20) channel turns: when, role,
|
|
6190
|
+
duration, severity, request preview.
|
|
6191
|
+
--raw / -r include full request/response payloads
|
|
6192
|
+
--live / -f tail the log; print new turns as they arrive
|
|
6193
|
+
(Ctrl-C to exit)
|
|
6194
|
+
--tmux escape hatch \u2014 print the raw pueue/tmux
|
|
6195
|
+
pane log instead
|
|
6196
|
+
attach [--readonly] Attach to the tmux session hosting claude (Ctrl-B D to detach;
|
|
6197
|
+
--readonly / -r to attach view-only)
|
|
6198
|
+
test Send a smoke-test classification through the channel
|
|
6199
|
+
help Show this message
|
|
6200
|
+
|
|
6201
|
+
CONFIGURATION
|
|
6202
|
+
Provider toggle (single global switch, default 'inngest'):
|
|
6203
|
+
Stored in ${dbPath} (settings table, key='inference_provider').
|
|
6204
|
+
Toggle via: synkro local-cc enable / disable
|
|
6205
|
+
Or directly via SQL:
|
|
6206
|
+
INSERT OR REPLACE INTO settings (key, value) VALUES ('inference_provider', 'local-cc');
|
|
6207
|
+
|
|
6208
|
+
Claude Code session settings (scoped to ~/.synkro/cc_sessions only):
|
|
6209
|
+
${PLUGIN_SETTINGS_PATH}
|
|
6210
|
+
Currently sets {"fastMode": true}. Edit to add other CC settings for this
|
|
6211
|
+
session without affecting your other CC projects.
|
|
6212
|
+
|
|
6213
|
+
MCP server registration (for the channel plugin):
|
|
6214
|
+
${CLAUDE_JSON_PATH}
|
|
6215
|
+
A 'synkro-local' entry under mcpServers, pointing at:
|
|
6216
|
+
bun ${PLUGIN_PATH}
|
|
6217
|
+
Workspace trust is also pre-accepted under projects[\`${SESSION_DIR}\`].
|
|
6218
|
+
|
|
6219
|
+
Channel runtime files:
|
|
6220
|
+
${RUN_SCRIPT_PATH}
|
|
6221
|
+
bash wrapper that pueue invokes; owns the tmux session lifecycle.
|
|
6222
|
+
${PLUGIN_PATH}
|
|
6223
|
+
Bun MCP channel plugin (auto-generated, do not edit).
|
|
6224
|
+
127.0.0.1:${CHANNEL_PORT}
|
|
6225
|
+
Loopback TCP endpoint the CLI POSTs to in order to submit a request.
|
|
6226
|
+
|
|
6227
|
+
ENVIRONMENT VARIABLES
|
|
6228
|
+
SYNKRO_CHANNEL_PORT Override the TCP port used by both the plugin and
|
|
6229
|
+
the CLI client (loopback only). Default: 8929
|
|
6230
|
+
SYNKRO_CHANNEL_TIMEOUT_MS Per-request timeout inside the channel plugin
|
|
6231
|
+
(default: 120000)
|
|
6232
|
+
|
|
6233
|
+
REQUIRED TOOLS
|
|
6234
|
+
claude Claude Code CLI, authenticated to your subscription
|
|
6235
|
+
pueue pueue + pueued (https://github.com/Nukesor/pueue) running
|
|
6236
|
+
tmux For detached pty around claude
|
|
6237
|
+
bun Runtime for the MCP channel plugin
|
|
6238
|
+
|
|
6239
|
+
TROUBLESHOOTING
|
|
6240
|
+
\u2022 Channel unreachable after \`enable\`?
|
|
6241
|
+
synkro local-cc logs # check pueue task output
|
|
6242
|
+
tmux attach -t ${TMUX_SESSION_NAME} # see what claude is showing
|
|
6243
|
+
\u2022 Stale state after a crash:
|
|
6244
|
+
synkro local-cc stop && synkro local-cc start
|
|
6245
|
+
\u2022 Want to inspect or interact with the live session:
|
|
6246
|
+
synkro local-cc attach
|
|
6247
|
+
`);
|
|
6248
|
+
}
|
|
6249
|
+
async function cmdStatus() {
|
|
6250
|
+
console.log(`Inference provider: ${getInferenceProvider()}`);
|
|
6251
|
+
try {
|
|
6252
|
+
assertPueueInstalled();
|
|
6253
|
+
} catch (err) {
|
|
6254
|
+
console.log(`Pueue: NOT AVAILABLE (${err.message})`);
|
|
6255
|
+
return;
|
|
6256
|
+
}
|
|
6257
|
+
const t = findTask();
|
|
6258
|
+
if (!t) {
|
|
6259
|
+
console.log("Pueue task: not present");
|
|
6260
|
+
} else {
|
|
6261
|
+
console.log(`Pueue task: id=${t.id} status=${t.status} cwd=${t.cwd}`);
|
|
6262
|
+
console.log(` command: ${t.command}`);
|
|
6263
|
+
}
|
|
6264
|
+
const channelUp = await isChannelAvailable();
|
|
6265
|
+
console.log(`Channel ${CHANNEL_HOST}:${CHANNEL_PORT}: ${channelUp ? "reachable" : "unreachable"}`);
|
|
6266
|
+
const tmuxCheck = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
|
|
6267
|
+
console.log(`tmux session '${TMUX_SESSION_NAME}': ${tmuxCheck.status === 0 ? "live" : "absent"}`);
|
|
6268
|
+
}
|
|
6269
|
+
async function cmdEnable() {
|
|
6270
|
+
assertClaudeInstalled();
|
|
6271
|
+
assertPueueInstalled();
|
|
6272
|
+
assertTmuxInstalled();
|
|
6273
|
+
console.log("Installing local-CC channel plugin...");
|
|
6274
|
+
const r = installLocalCC();
|
|
6275
|
+
console.log(` plugin: ${r.pluginPath}`);
|
|
6276
|
+
console.log(` cwd: ${r.sessionDir}`);
|
|
6277
|
+
console.log("Starting pueue task...");
|
|
6278
|
+
const t = ensureRunning();
|
|
6279
|
+
console.log(` task: id=${t.id} status=${t.status}`);
|
|
6280
|
+
console.log("Waiting for channel (auto-confirming any CC prompts)...");
|
|
6281
|
+
const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
|
|
6282
|
+
if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
6283
|
+
else console.warn(` \u26A0 channel did not come up within 60s \u2014 check \`synkro local-cc logs\``);
|
|
6284
|
+
setInferenceProvider("local-cc");
|
|
6285
|
+
console.log("Inference provider set to local-cc.");
|
|
6286
|
+
}
|
|
6287
|
+
function cmdDisable() {
|
|
6288
|
+
setInferenceProvider("inngest");
|
|
6289
|
+
console.log("Inference provider set to inngest. (Pueue task left running \u2014 use `synkro local-cc stop` to terminate.)");
|
|
6290
|
+
}
|
|
6291
|
+
async function cmdStart() {
|
|
6292
|
+
assertClaudeInstalled();
|
|
6293
|
+
assertPueueInstalled();
|
|
6294
|
+
assertTmuxInstalled();
|
|
6295
|
+
const t = ensureRunning();
|
|
6296
|
+
console.log(`Pueue task: id=${t.id} status=${t.status}`);
|
|
6297
|
+
const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
|
|
6298
|
+
console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
|
|
6299
|
+
}
|
|
6300
|
+
function cmdStop() {
|
|
6301
|
+
stopTask();
|
|
6302
|
+
console.log("Pueue task stopped.");
|
|
6303
|
+
}
|
|
6304
|
+
async function cmdRestart() {
|
|
6305
|
+
stopTask();
|
|
6306
|
+
const t = startTask();
|
|
6307
|
+
console.log(`Pueue task restarted: id=${t.id} status=${t.status}`);
|
|
6308
|
+
const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
|
|
6309
|
+
console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
|
|
6310
|
+
}
|
|
6311
|
+
function relativeTime(iso) {
|
|
6312
|
+
const ts = new Date(iso).getTime();
|
|
6313
|
+
if (!Number.isFinite(ts)) return iso;
|
|
6314
|
+
const sec = Math.max(0, Math.round((Date.now() - ts) / 1e3));
|
|
6315
|
+
if (sec < 60) return `${sec}s ago`;
|
|
6316
|
+
if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
|
|
6317
|
+
if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
|
|
6318
|
+
return `${Math.round(sec / 86400)}d ago`;
|
|
6319
|
+
}
|
|
6320
|
+
function colorize(s, code) {
|
|
6321
|
+
if (!process.stdout.isTTY) return s;
|
|
6322
|
+
return `\x1B[${code}m${s}\x1B[0m`;
|
|
6323
|
+
}
|
|
6324
|
+
function statusGlyph(t) {
|
|
6325
|
+
if (t.status === "ok") return colorize("\u2713", 32);
|
|
6326
|
+
if (t.status === "timeout") return colorize("\u23F1", 33);
|
|
6327
|
+
return colorize("\u2717", 31);
|
|
6328
|
+
}
|
|
6329
|
+
function severityCell(t) {
|
|
6330
|
+
if (t.severity) {
|
|
6331
|
+
const sev = t.severity;
|
|
6332
|
+
if (sev === "block" || sev === "violations" || sev === "unclear") return colorize(sev, 33);
|
|
6333
|
+
return colorize(sev, 36);
|
|
6334
|
+
}
|
|
6335
|
+
if (t.error) return colorize(t.error.slice(0, 40), 31);
|
|
6336
|
+
return "\u2014";
|
|
6337
|
+
}
|
|
6338
|
+
function firstLine(s) {
|
|
6339
|
+
return s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
6340
|
+
}
|
|
6341
|
+
function formatTurn(t, raw) {
|
|
6342
|
+
const when = relativeTime(t.ts).padEnd(8);
|
|
6343
|
+
const dur = (t.duration_ms < 1e3 ? `${t.duration_ms}ms` : `${(t.duration_ms / 1e3).toFixed(1)}s`).padStart(7);
|
|
6344
|
+
const role = t.role.padEnd(24);
|
|
6345
|
+
const sev = severityCell(t).padEnd(20);
|
|
6346
|
+
const preview = (() => {
|
|
6347
|
+
const req = firstLine(t.request_preview).slice(0, 60);
|
|
6348
|
+
return colorize(req, 90);
|
|
6349
|
+
})();
|
|
6350
|
+
const head = `${statusGlyph(t)} ${when} ${dur} ${role} ${sev} ${preview}`;
|
|
6351
|
+
if (!raw) return head;
|
|
6352
|
+
const blocks = [head];
|
|
6353
|
+
blocks.push(colorize(" request:", 90));
|
|
6354
|
+
blocks.push(" " + t.request_preview.replace(/\n/g, "\n "));
|
|
6355
|
+
if (t.response_preview) {
|
|
6356
|
+
blocks.push(colorize(" response:", 90));
|
|
6357
|
+
blocks.push(" " + t.response_preview.replace(/\n/g, "\n "));
|
|
6358
|
+
}
|
|
6359
|
+
if (t.error) {
|
|
6360
|
+
blocks.push(colorize(" error:", 31) + " " + t.error);
|
|
6361
|
+
}
|
|
6362
|
+
return blocks.join("\n");
|
|
6363
|
+
}
|
|
6364
|
+
function cmdLogs(rest) {
|
|
6365
|
+
let n = 20;
|
|
6366
|
+
let raw = false;
|
|
6367
|
+
let live = false;
|
|
6368
|
+
for (const arg of rest) {
|
|
6369
|
+
if (arg === "--raw" || arg === "-r") raw = true;
|
|
6370
|
+
else if (arg === "--live" || arg === "-f") live = true;
|
|
6371
|
+
else if (arg === "--tmux") {
|
|
6372
|
+
console.log(tailLogs(80));
|
|
6373
|
+
return;
|
|
6374
|
+
} else {
|
|
6375
|
+
const parsed = parseInt(arg, 10);
|
|
6376
|
+
if (parsed > 0) n = parsed;
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
const header = " " + colorize("status when dur role severity request", 90);
|
|
6380
|
+
const turns = readRecentTurns(n);
|
|
6381
|
+
if (turns.length === 0) {
|
|
6382
|
+
if (!live) {
|
|
6383
|
+
console.log(`No turns logged yet at ${TURN_LOG_PATH}.`);
|
|
6384
|
+
console.log("Run a few requests through the channel (synkro local-cc test) and try again.");
|
|
6385
|
+
return;
|
|
6386
|
+
}
|
|
6387
|
+
console.log(`No turns logged yet at ${TURN_LOG_PATH} \u2014 waiting for new entries\u2026 (Ctrl-C to exit)`);
|
|
6388
|
+
} else {
|
|
6389
|
+
console.log(`Last ${turns.length} channel turn(s) (newest first):`);
|
|
6390
|
+
console.log(header);
|
|
6391
|
+
for (const t of turns) console.log(" " + formatTurn(t, raw));
|
|
6392
|
+
}
|
|
6393
|
+
if (!live) {
|
|
6394
|
+
if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
|
|
6395
|
+
return;
|
|
6396
|
+
}
|
|
6397
|
+
return new Promise((resolve2) => {
|
|
6398
|
+
console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
|
|
6399
|
+
const stop = followTurns((t) => {
|
|
6400
|
+
console.log(" " + formatTurn(t, raw));
|
|
6401
|
+
});
|
|
6402
|
+
const onSigint = () => {
|
|
6403
|
+
stop();
|
|
6404
|
+
process.removeListener("SIGINT", onSigint);
|
|
6405
|
+
resolve2();
|
|
6406
|
+
};
|
|
6407
|
+
process.on("SIGINT", onSigint);
|
|
6408
|
+
});
|
|
6409
|
+
}
|
|
6410
|
+
function cmdAttach(rest) {
|
|
6411
|
+
assertTmuxInstalled();
|
|
6412
|
+
const readonly = rest.some((a) => a === "--readonly" || a === "-r");
|
|
6413
|
+
const has = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
|
|
6414
|
+
if (has.status !== 0) {
|
|
6415
|
+
console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
|
|
6416
|
+
process.exit(1);
|
|
6417
|
+
}
|
|
6418
|
+
if (!process.stdout.isTTY) {
|
|
6419
|
+
console.error("attach requires a TTY. Run this command directly in your terminal, not piped.");
|
|
6420
|
+
process.exit(1);
|
|
6421
|
+
}
|
|
6422
|
+
console.log(`Attaching to tmux session '${TMUX_SESSION_NAME}'${readonly ? " (read-only)" : ""}.`);
|
|
6423
|
+
console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
|
|
6424
|
+
console.log();
|
|
6425
|
+
const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
|
|
6426
|
+
const r = spawnSync3("tmux", args2, { stdio: "inherit" });
|
|
6427
|
+
process.exit(r.status ?? 0);
|
|
6428
|
+
}
|
|
6429
|
+
async function cmdTest() {
|
|
6430
|
+
console.log("Sending smoke-test grading request through the channel...");
|
|
6431
|
+
const result = await submitToChannel(
|
|
6432
|
+
"grade-bash",
|
|
6433
|
+
'Command: echo "hello world"\nIntent: testing channel connectivity'
|
|
6434
|
+
);
|
|
6435
|
+
console.log("Raw reply:");
|
|
6436
|
+
console.log(result);
|
|
6437
|
+
}
|
|
6438
|
+
function cmdInstall() {
|
|
6439
|
+
const r = installLocalCC();
|
|
6440
|
+
console.log(`Reinstalled plugin at ${r.pluginPath}`);
|
|
6441
|
+
}
|
|
6442
|
+
async function localCcCommand(args2) {
|
|
6443
|
+
const sub = args2[0] ?? "";
|
|
6444
|
+
try {
|
|
6445
|
+
switch (sub) {
|
|
6446
|
+
case "enable":
|
|
6447
|
+
await cmdEnable();
|
|
6448
|
+
break;
|
|
6449
|
+
case "disable":
|
|
6450
|
+
cmdDisable();
|
|
6451
|
+
break;
|
|
6452
|
+
case "status":
|
|
6453
|
+
await cmdStatus();
|
|
6454
|
+
break;
|
|
6455
|
+
case "start":
|
|
6456
|
+
await cmdStart();
|
|
6457
|
+
break;
|
|
6458
|
+
case "stop":
|
|
6459
|
+
cmdStop();
|
|
6460
|
+
break;
|
|
6461
|
+
case "restart":
|
|
6462
|
+
await cmdRestart();
|
|
6463
|
+
break;
|
|
6464
|
+
case "install":
|
|
6465
|
+
cmdInstall();
|
|
6466
|
+
break;
|
|
6467
|
+
case "logs":
|
|
6468
|
+
await cmdLogs(args2.slice(1));
|
|
6469
|
+
break;
|
|
6470
|
+
case "attach":
|
|
6471
|
+
cmdAttach(args2.slice(1));
|
|
6472
|
+
break;
|
|
6473
|
+
case "test":
|
|
6474
|
+
await cmdTest();
|
|
6475
|
+
break;
|
|
6476
|
+
case "":
|
|
6477
|
+
case "help":
|
|
6478
|
+
case "--help":
|
|
6479
|
+
case "-h":
|
|
6480
|
+
printHelp();
|
|
6481
|
+
break;
|
|
6482
|
+
default:
|
|
6483
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
6484
|
+
printHelp();
|
|
6485
|
+
process.exit(1);
|
|
6486
|
+
}
|
|
6487
|
+
} catch (err) {
|
|
6488
|
+
console.error(err.message);
|
|
6489
|
+
process.exit(1);
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
var init_localCc = __esm({
|
|
6493
|
+
"cli/commands/localCc.ts"() {
|
|
6494
|
+
"use strict";
|
|
5739
6495
|
init_install();
|
|
6496
|
+
init_turnLog();
|
|
6497
|
+
init_pueue();
|
|
6498
|
+
init_settings();
|
|
6499
|
+
init_client();
|
|
6500
|
+
}
|
|
6501
|
+
});
|
|
6502
|
+
|
|
6503
|
+
// cli/commands/grade.ts
|
|
6504
|
+
var grade_exports = {};
|
|
6505
|
+
__export(grade_exports, {
|
|
6506
|
+
gradeCommand: () => gradeCommand
|
|
6507
|
+
});
|
|
6508
|
+
async function readStdin() {
|
|
6509
|
+
return new Promise((resolve2, reject) => {
|
|
6510
|
+
const chunks = [];
|
|
6511
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
6512
|
+
process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
6513
|
+
process.stdin.on("error", reject);
|
|
6514
|
+
});
|
|
6515
|
+
}
|
|
6516
|
+
async function gradeCommand(args2) {
|
|
6517
|
+
const mode = args2[0] ?? "";
|
|
6518
|
+
let role;
|
|
6519
|
+
if (mode === "edit") role = "grade-edit";
|
|
6520
|
+
else if (mode === "bash") role = "grade-bash";
|
|
6521
|
+
else {
|
|
6522
|
+
console.error("Usage: synkro grade <edit|bash>");
|
|
6523
|
+
process.exit(2);
|
|
6524
|
+
}
|
|
6525
|
+
const payload = await readStdin();
|
|
6526
|
+
if (!payload.trim()) {
|
|
6527
|
+
console.error("synkro grade: empty stdin");
|
|
6528
|
+
process.exit(2);
|
|
6529
|
+
}
|
|
6530
|
+
try {
|
|
6531
|
+
const result = await submitToChannel(role, payload, { timeoutMs: 6e4 });
|
|
6532
|
+
process.stdout.write(result);
|
|
6533
|
+
if (!result.endsWith("\n")) process.stdout.write("\n");
|
|
6534
|
+
} catch (err) {
|
|
6535
|
+
if (err instanceof LocalCCError) {
|
|
6536
|
+
console.error(`synkro grade: ${err.message}`);
|
|
6537
|
+
} else {
|
|
6538
|
+
console.error(`synkro grade: ${err.message}`);
|
|
6539
|
+
}
|
|
6540
|
+
process.exit(3);
|
|
6541
|
+
}
|
|
6542
|
+
}
|
|
6543
|
+
var init_grade = __esm({
|
|
6544
|
+
"cli/commands/grade.ts"() {
|
|
6545
|
+
"use strict";
|
|
6546
|
+
init_client();
|
|
5740
6547
|
}
|
|
5741
6548
|
});
|
|
5742
6549
|
|
|
5743
6550
|
// cli/bootstrap.js
|
|
5744
|
-
import { readFileSync as
|
|
6551
|
+
import { readFileSync as readFileSync13, existsSync as existsSync17 } from "fs";
|
|
5745
6552
|
import { resolve } from "path";
|
|
5746
6553
|
var envCandidates = [
|
|
5747
6554
|
resolve(process.cwd(), ".env"),
|
|
5748
6555
|
resolve(process.env.HOME ?? "", ".synkro", "config.env")
|
|
5749
6556
|
];
|
|
5750
6557
|
for (const envPath of envCandidates) {
|
|
5751
|
-
if (!
|
|
5752
|
-
const envContent =
|
|
6558
|
+
if (!existsSync17(envPath)) continue;
|
|
6559
|
+
const envContent = readFileSync13(envPath, "utf-8");
|
|
5753
6560
|
for (const line of envContent.split("\n")) {
|
|
5754
6561
|
const trimmed = line.trim();
|
|
5755
6562
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -5763,7 +6570,7 @@ for (const envPath of envCandidates) {
|
|
|
5763
6570
|
var args = process.argv.slice(2);
|
|
5764
6571
|
var cmd = args[0] || "";
|
|
5765
6572
|
var subArgs = args.slice(1);
|
|
5766
|
-
function
|
|
6573
|
+
function printHelp2() {
|
|
5767
6574
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|
|
5768
6575
|
|
|
5769
6576
|
Usage:
|
|
@@ -5783,6 +6590,8 @@ Commands:
|
|
|
5783
6590
|
disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
|
|
5784
6591
|
uninstall Fully remove Synkro from this machine
|
|
5785
6592
|
reinstall Clean uninstall + fresh install
|
|
6593
|
+
local-cc <sub> Manage the local Claude Code inference session (enable/disable/status/start/stop/logs/test)
|
|
6594
|
+
grade <edit|bash> Internal: read prompt from stdin, return verdict (called by hook scripts)
|
|
5786
6595
|
help Show this message
|
|
5787
6596
|
|
|
5788
6597
|
Quick start:
|
|
@@ -5795,7 +6604,7 @@ Quick start:
|
|
|
5795
6604
|
async function main() {
|
|
5796
6605
|
switch (cmd) {
|
|
5797
6606
|
case "install": {
|
|
5798
|
-
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (
|
|
6607
|
+
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install2(), install_exports));
|
|
5799
6608
|
await installCommand2(parseArgs2(subArgs));
|
|
5800
6609
|
break;
|
|
5801
6610
|
}
|
|
@@ -5866,16 +6675,26 @@ async function main() {
|
|
|
5866
6675
|
await reinstallCommand2();
|
|
5867
6676
|
break;
|
|
5868
6677
|
}
|
|
6678
|
+
case "local-cc": {
|
|
6679
|
+
const { localCcCommand: localCcCommand2 } = await Promise.resolve().then(() => (init_localCc(), localCc_exports));
|
|
6680
|
+
await localCcCommand2(subArgs);
|
|
6681
|
+
break;
|
|
6682
|
+
}
|
|
6683
|
+
case "grade": {
|
|
6684
|
+
const { gradeCommand: gradeCommand2 } = await Promise.resolve().then(() => (init_grade(), grade_exports));
|
|
6685
|
+
await gradeCommand2(subArgs);
|
|
6686
|
+
break;
|
|
6687
|
+
}
|
|
5869
6688
|
case "help":
|
|
5870
6689
|
case "--help":
|
|
5871
6690
|
case "-h":
|
|
5872
6691
|
case "": {
|
|
5873
|
-
|
|
6692
|
+
printHelp2();
|
|
5874
6693
|
break;
|
|
5875
6694
|
}
|
|
5876
6695
|
default: {
|
|
5877
6696
|
console.error(`Unknown command: ${cmd}`);
|
|
5878
|
-
|
|
6697
|
+
printHelp2();
|
|
5879
6698
|
process.exit(1);
|
|
5880
6699
|
}
|
|
5881
6700
|
}
|