@synkro-sh/cli 1.3.59 → 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 +1602 -752
- package/dist/bootstrap.js.map +1 -1
- package/package.json +2 -1
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";
|
|
@@ -3839,15 +3303,699 @@ var init_setupGithub = __esm({
|
|
|
3839
3303
|
}
|
|
3840
3304
|
});
|
|
3841
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
|
+
|
|
3842
3990
|
// cli/commands/install.ts
|
|
3843
3991
|
var install_exports = {};
|
|
3844
3992
|
__export(install_exports, {
|
|
3845
3993
|
installCommand: () => installCommand,
|
|
3846
3994
|
parseArgs: () => parseArgs
|
|
3847
3995
|
});
|
|
3848
|
-
import { existsSync as
|
|
3849
|
-
import { homedir as
|
|
3850
|
-
import { join 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";
|
|
3851
3999
|
import { execSync as execSync5 } from "child_process";
|
|
3852
4000
|
import { createInterface as createInterface3 } from "readline";
|
|
3853
4001
|
function sanitizeGatewayCandidate(raw) {
|
|
@@ -3884,43 +4032,33 @@ async function promptTranscriptConsent() {
|
|
|
3884
4032
|
});
|
|
3885
4033
|
}
|
|
3886
4034
|
function ensureSynkroDir() {
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
}
|
|
3892
|
-
function writeGraderDaemon() {
|
|
3893
|
-
writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
|
|
3894
|
-
chmodSync(GRADER_DAEMON_PATH, 493);
|
|
3895
|
-
for (const p of [GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH]) {
|
|
3896
|
-
try {
|
|
3897
|
-
__require("fs").unlinkSync(p);
|
|
3898
|
-
} catch {
|
|
3899
|
-
}
|
|
3900
|
-
}
|
|
4035
|
+
mkdirSync8(SYNKRO_DIR3, { recursive: true });
|
|
4036
|
+
mkdirSync8(HOOKS_DIR, { recursive: true });
|
|
4037
|
+
mkdirSync8(BIN_DIR, { recursive: true });
|
|
4038
|
+
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
3901
4039
|
}
|
|
3902
4040
|
function writeHookScripts() {
|
|
3903
|
-
const bashScriptPath =
|
|
3904
|
-
const bashFollowupScriptPath =
|
|
3905
|
-
const editCaptureScriptPath =
|
|
3906
|
-
const editPrecheckScriptPath =
|
|
3907
|
-
const stopSummaryScriptPath =
|
|
3908
|
-
const sessionStartScriptPath =
|
|
3909
|
-
const transcriptSyncScriptPath =
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
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);
|
|
3924
4062
|
return {
|
|
3925
4063
|
bashScript: bashScriptPath,
|
|
3926
4064
|
bashFollowupScript: bashFollowupScriptPath,
|
|
@@ -3938,14 +4076,20 @@ function sanitizeConfigValue(raw, maxLen = 256) {
|
|
|
3938
4076
|
function shellQuoteSingle(value) {
|
|
3939
4077
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
3940
4078
|
}
|
|
4079
|
+
function resolveSynkroBundle() {
|
|
4080
|
+
const scriptPath = process.argv[1];
|
|
4081
|
+
if (scriptPath && existsSync10(scriptPath)) return scriptPath;
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
3941
4084
|
function writeConfigEnv(opts) {
|
|
3942
|
-
const credsPath =
|
|
4085
|
+
const credsPath = join10(SYNKRO_DIR3, "credentials.json");
|
|
3943
4086
|
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
3944
4087
|
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
3945
4088
|
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
3946
4089
|
const safeEmail = sanitizeConfigValue(opts.email);
|
|
3947
4090
|
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
3948
4091
|
const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
|
|
4092
|
+
const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
|
|
3949
4093
|
const lines = [
|
|
3950
4094
|
"# Synkro CLI config (managed by synkro install)",
|
|
3951
4095
|
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
@@ -3954,8 +4098,9 @@ function writeConfigEnv(opts) {
|
|
|
3954
4098
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
3955
4099
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
3956
4100
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
3957
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.
|
|
4101
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.0")}`
|
|
3958
4102
|
];
|
|
4103
|
+
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
3959
4104
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
3960
4105
|
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
3961
4106
|
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
@@ -3963,8 +4108,8 @@ function writeConfigEnv(opts) {
|
|
|
3963
4108
|
lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
|
|
3964
4109
|
}
|
|
3965
4110
|
lines.push("");
|
|
3966
|
-
|
|
3967
|
-
|
|
4111
|
+
writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
|
|
4112
|
+
chmodSync2(CONFIG_PATH2, 384);
|
|
3968
4113
|
}
|
|
3969
4114
|
function collectLocalMetadata() {
|
|
3970
4115
|
const meta = { platform: process.platform };
|
|
@@ -3984,16 +4129,16 @@ function collectLocalMetadata() {
|
|
|
3984
4129
|
meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
|
|
3985
4130
|
} catch {
|
|
3986
4131
|
}
|
|
3987
|
-
const claudeDir =
|
|
4132
|
+
const claudeDir = join10(homedir9(), ".claude");
|
|
3988
4133
|
try {
|
|
3989
|
-
const settings = JSON.parse(
|
|
4134
|
+
const settings = JSON.parse(readFileSync7(join10(claudeDir, "settings.json"), "utf-8"));
|
|
3990
4135
|
const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
|
|
3991
4136
|
if (plugins.length) meta.enabled_plugins = plugins;
|
|
3992
4137
|
if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
|
|
3993
4138
|
} catch {
|
|
3994
4139
|
}
|
|
3995
4140
|
try {
|
|
3996
|
-
const mcpCache = JSON.parse(
|
|
4141
|
+
const mcpCache = JSON.parse(readFileSync7(join10(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
|
|
3997
4142
|
const mcpNames = Object.keys(mcpCache);
|
|
3998
4143
|
if (mcpNames.length) meta.mcp_servers = mcpNames;
|
|
3999
4144
|
} catch {
|
|
@@ -4005,10 +4150,10 @@ function collectLocalMetadata() {
|
|
|
4005
4150
|
} catch {
|
|
4006
4151
|
}
|
|
4007
4152
|
try {
|
|
4008
|
-
const sessionsDir =
|
|
4153
|
+
const sessionsDir = join10(claudeDir, "sessions");
|
|
4009
4154
|
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
|
|
4010
4155
|
for (const f of files) {
|
|
4011
|
-
const s = JSON.parse(
|
|
4156
|
+
const s = JSON.parse(readFileSync7(join10(sessionsDir, f), "utf-8"));
|
|
4012
4157
|
if (s.version) {
|
|
4013
4158
|
meta.cc_version = meta.cc_version || s.version;
|
|
4014
4159
|
break;
|
|
@@ -4063,19 +4208,19 @@ function assertGatewayAllowed(gatewayUrl) {
|
|
|
4063
4208
|
}
|
|
4064
4209
|
function isAlreadyInstalled() {
|
|
4065
4210
|
const requiredScripts = [
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
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")
|
|
4072
4217
|
];
|
|
4073
|
-
if (!requiredScripts.every((p) =>
|
|
4074
|
-
if (!
|
|
4075
|
-
const settingsPath =
|
|
4076
|
-
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;
|
|
4077
4222
|
try {
|
|
4078
|
-
const settings = JSON.parse(
|
|
4223
|
+
const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
4079
4224
|
const hooks = settings?.hooks;
|
|
4080
4225
|
if (!hooks || typeof hooks !== "object") return false;
|
|
4081
4226
|
const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
|
|
@@ -4182,23 +4327,31 @@ async function installCommand(opts = {}) {
|
|
|
4182
4327
|
console.log(` ${scripts.sessionStartScript}`);
|
|
4183
4328
|
console.log(` ${scripts.transcriptSyncScript}
|
|
4184
4329
|
`);
|
|
4185
|
-
writeGraderDaemon();
|
|
4186
4330
|
for (const mode of ["edit", "bash"]) {
|
|
4187
|
-
const pidFile =
|
|
4331
|
+
const pidFile = join10(SYNKRO_DIR3, "daemon", mode, "daemon.pid");
|
|
4188
4332
|
try {
|
|
4189
|
-
const pid = parseInt(
|
|
4333
|
+
const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
|
|
4190
4334
|
if (pid > 0) {
|
|
4191
4335
|
process.kill(pid, "SIGTERM");
|
|
4192
|
-
console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
|
|
4336
|
+
console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
|
|
4193
4337
|
}
|
|
4194
4338
|
} catch {
|
|
4195
4339
|
}
|
|
4196
4340
|
}
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
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}
|
|
4201
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
|
+
}
|
|
4202
4355
|
let transcriptConsent = true;
|
|
4203
4356
|
if (process.stdin.isTTY) {
|
|
4204
4357
|
transcriptConsent = await promptTranscriptConsent();
|
|
@@ -4264,10 +4417,19 @@ async function installCommand(opts = {}) {
|
|
|
4264
4417
|
} catch {
|
|
4265
4418
|
}
|
|
4266
4419
|
const profile = await fetchUserProfile(gatewayUrl, token);
|
|
4267
|
-
|
|
4420
|
+
const synkroBundle = resolveSynkroBundle();
|
|
4421
|
+
writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent });
|
|
4268
4422
|
console.log(`Wrote config to ${CONFIG_PATH2}`);
|
|
4269
|
-
console.log(` inference: ${profile.inference} (server-side grading)
|
|
4270
|
-
`);
|
|
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();
|
|
4271
4433
|
if (transcriptConsent) {
|
|
4272
4434
|
try {
|
|
4273
4435
|
const repo = detectGitRepo2();
|
|
@@ -4314,17 +4476,17 @@ function detectGitRepo2() {
|
|
|
4314
4476
|
function getClaudeProjectsFolder() {
|
|
4315
4477
|
const cwd = process.cwd();
|
|
4316
4478
|
const sanitized = "-" + cwd.replace(/\//g, "-");
|
|
4317
|
-
const projectsDir =
|
|
4318
|
-
return
|
|
4479
|
+
const projectsDir = join10(homedir9(), ".claude", "projects", sanitized);
|
|
4480
|
+
return existsSync10(projectsDir) ? projectsDir : null;
|
|
4319
4481
|
}
|
|
4320
4482
|
function extractSessionInsights(projectsDir) {
|
|
4321
4483
|
const insights = [];
|
|
4322
4484
|
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
4323
4485
|
for (const file of files) {
|
|
4324
4486
|
const sessionId = file.replace(".jsonl", "");
|
|
4325
|
-
const filePath =
|
|
4487
|
+
const filePath = join10(projectsDir, file);
|
|
4326
4488
|
try {
|
|
4327
|
-
const content =
|
|
4489
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4328
4490
|
const lines = content.split("\n").filter(Boolean);
|
|
4329
4491
|
for (let i = 0; i < lines.length; i++) {
|
|
4330
4492
|
try {
|
|
@@ -4400,7 +4562,7 @@ function extractTextContent(content) {
|
|
|
4400
4562
|
return "";
|
|
4401
4563
|
}
|
|
4402
4564
|
function parseTranscriptFile(filePath) {
|
|
4403
|
-
const content =
|
|
4565
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4404
4566
|
const lines = content.split("\n").filter(Boolean);
|
|
4405
4567
|
const messages = [];
|
|
4406
4568
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -4451,7 +4613,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
|
4451
4613
|
const sessions = [];
|
|
4452
4614
|
for (const file of batch) {
|
|
4453
4615
|
const sessionId = file.replace(".jsonl", "");
|
|
4454
|
-
const filePath =
|
|
4616
|
+
const filePath = join10(projectsDir, file);
|
|
4455
4617
|
try {
|
|
4456
4618
|
const allMessages = parseTranscriptFile(filePath);
|
|
4457
4619
|
const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
|
|
@@ -4480,38 +4642,38 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
|
4480
4642
|
}
|
|
4481
4643
|
for (const file of batch) {
|
|
4482
4644
|
const sessionId = file.replace(".jsonl", "");
|
|
4483
|
-
const filePath =
|
|
4645
|
+
const filePath = join10(projectsDir, file);
|
|
4484
4646
|
try {
|
|
4485
|
-
const content =
|
|
4647
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
4486
4648
|
const lineCount = content.split("\n").filter(Boolean).length;
|
|
4487
|
-
|
|
4649
|
+
writeFileSync7(join10(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
|
|
4488
4650
|
} catch {
|
|
4489
4651
|
}
|
|
4490
4652
|
}
|
|
4491
4653
|
}
|
|
4492
4654
|
return { sessions: totalSessions, messages: totalMessages };
|
|
4493
4655
|
}
|
|
4494
|
-
var
|
|
4495
|
-
var
|
|
4656
|
+
var SYNKRO_DIR3, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, OFFSETS_DIR;
|
|
4657
|
+
var init_install2 = __esm({
|
|
4496
4658
|
"cli/commands/install.ts"() {
|
|
4497
4659
|
"use strict";
|
|
4498
4660
|
init_agentDetect();
|
|
4499
4661
|
init_ccHookConfig();
|
|
4500
4662
|
init_mcpConfig();
|
|
4501
4663
|
init_hookScripts();
|
|
4502
|
-
init_graderDaemon();
|
|
4503
4664
|
init_stub();
|
|
4504
4665
|
init_repoConnect();
|
|
4505
4666
|
init_projects();
|
|
4506
4667
|
init_setupGithub();
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
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");
|
|
4515
4677
|
}
|
|
4516
4678
|
});
|
|
4517
4679
|
|
|
@@ -4587,13 +4749,13 @@ var status_exports = {};
|
|
|
4587
4749
|
__export(status_exports, {
|
|
4588
4750
|
statusCommand: () => statusCommand
|
|
4589
4751
|
});
|
|
4590
|
-
import { existsSync as
|
|
4591
|
-
import { homedir as
|
|
4592
|
-
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";
|
|
4593
4755
|
function readConfigEnv() {
|
|
4594
|
-
if (!
|
|
4756
|
+
if (!existsSync11(CONFIG_PATH3)) return {};
|
|
4595
4757
|
const out = {};
|
|
4596
|
-
const raw =
|
|
4758
|
+
const raw = readFileSync8(CONFIG_PATH3, "utf-8");
|
|
4597
4759
|
for (const line of raw.split("\n")) {
|
|
4598
4760
|
const trimmed = line.trim();
|
|
4599
4761
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -4666,19 +4828,19 @@ async function statusCommand() {
|
|
|
4666
4828
|
}
|
|
4667
4829
|
}
|
|
4668
4830
|
console.log();
|
|
4669
|
-
const bashScript =
|
|
4670
|
-
const bashFollowupScript =
|
|
4671
|
-
const editPrecheckScript =
|
|
4672
|
-
const editCaptureScript =
|
|
4673
|
-
const stopSummaryScript =
|
|
4674
|
-
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");
|
|
4675
4837
|
console.log("Hook scripts:");
|
|
4676
|
-
console.log(` ${
|
|
4677
|
-
console.log(` ${
|
|
4678
|
-
console.log(` ${
|
|
4679
|
-
console.log(` ${
|
|
4680
|
-
console.log(` ${
|
|
4681
|
-
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}`);
|
|
4682
4844
|
console.log();
|
|
4683
4845
|
const mcp = inspectMcpConfig();
|
|
4684
4846
|
console.log("Guardrails MCP server (Claude Code):");
|
|
@@ -4690,7 +4852,7 @@ async function statusCommand() {
|
|
|
4690
4852
|
console.log(` expected at ${mcp.configPath} \u2192 mcpServers.synkro-guardrails`);
|
|
4691
4853
|
}
|
|
4692
4854
|
}
|
|
4693
|
-
var
|
|
4855
|
+
var SYNKRO_DIR4, CONFIG_PATH3;
|
|
4694
4856
|
var init_status = __esm({
|
|
4695
4857
|
"cli/commands/status.ts"() {
|
|
4696
4858
|
"use strict";
|
|
@@ -4698,8 +4860,8 @@ var init_status = __esm({
|
|
|
4698
4860
|
init_agentDetect();
|
|
4699
4861
|
init_ccHookConfig();
|
|
4700
4862
|
init_mcpConfig();
|
|
4701
|
-
|
|
4702
|
-
CONFIG_PATH3 =
|
|
4863
|
+
SYNKRO_DIR4 = join11(homedir10(), ".synkro");
|
|
4864
|
+
CONFIG_PATH3 = join11(SYNKRO_DIR4, "config.env");
|
|
4703
4865
|
}
|
|
4704
4866
|
});
|
|
4705
4867
|
|
|
@@ -4788,13 +4950,13 @@ var config_exports = {};
|
|
|
4788
4950
|
__export(config_exports, {
|
|
4789
4951
|
configCommand: () => configCommand
|
|
4790
4952
|
});
|
|
4791
|
-
import { readFileSync as
|
|
4792
|
-
import { join as
|
|
4793
|
-
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";
|
|
4794
4956
|
function readConfigEnv2() {
|
|
4795
|
-
if (!
|
|
4957
|
+
if (!existsSync12(CONFIG_PATH4)) return {};
|
|
4796
4958
|
const out = {};
|
|
4797
|
-
for (const line of
|
|
4959
|
+
for (const line of readFileSync9(CONFIG_PATH4, "utf-8").split("\n")) {
|
|
4798
4960
|
const t = line.trim();
|
|
4799
4961
|
if (!t || t.startsWith("#")) continue;
|
|
4800
4962
|
const eq = t.indexOf("=");
|
|
@@ -4803,11 +4965,11 @@ function readConfigEnv2() {
|
|
|
4803
4965
|
return out;
|
|
4804
4966
|
}
|
|
4805
4967
|
function updateConfigValue(key, value) {
|
|
4806
|
-
if (!
|
|
4968
|
+
if (!existsSync12(CONFIG_PATH4)) {
|
|
4807
4969
|
console.error("No config found. Run `synkro install` first.");
|
|
4808
4970
|
process.exit(1);
|
|
4809
4971
|
}
|
|
4810
|
-
const lines =
|
|
4972
|
+
const lines = readFileSync9(CONFIG_PATH4, "utf-8").split("\n");
|
|
4811
4973
|
const pattern = new RegExp(`^${key}=`);
|
|
4812
4974
|
let found = false;
|
|
4813
4975
|
const updated = lines.map((line) => {
|
|
@@ -4818,7 +4980,7 @@ function updateConfigValue(key, value) {
|
|
|
4818
4980
|
return line;
|
|
4819
4981
|
});
|
|
4820
4982
|
if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
|
|
4821
|
-
|
|
4983
|
+
writeFileSync8(CONFIG_PATH4, updated.join("\n"), "utf-8");
|
|
4822
4984
|
}
|
|
4823
4985
|
async function configCommand(args2) {
|
|
4824
4986
|
if (args2.length === 0) {
|
|
@@ -4869,13 +5031,13 @@ To change: synkro config --inference fast|standard`);
|
|
|
4869
5031
|
updateConfigValue("SYNKRO_INFERENCE", inferenceValue);
|
|
4870
5032
|
console.log(`\u2713 Inference set to '${inferenceValue}'.`);
|
|
4871
5033
|
}
|
|
4872
|
-
var
|
|
5034
|
+
var SYNKRO_DIR5, CONFIG_PATH4;
|
|
4873
5035
|
var init_config = __esm({
|
|
4874
5036
|
"cli/commands/config.ts"() {
|
|
4875
5037
|
"use strict";
|
|
4876
5038
|
init_stub();
|
|
4877
|
-
|
|
4878
|
-
CONFIG_PATH4 =
|
|
5039
|
+
SYNKRO_DIR5 = join12(homedir11(), ".synkro");
|
|
5040
|
+
CONFIG_PATH4 = join12(SYNKRO_DIR5, "config.env");
|
|
4879
5041
|
}
|
|
4880
5042
|
});
|
|
4881
5043
|
|
|
@@ -4885,8 +5047,8 @@ __export(scanPr_exports, {
|
|
|
4885
5047
|
scanPrCommand: () => scanPrCommand
|
|
4886
5048
|
});
|
|
4887
5049
|
import { execSync as execSync6, spawn } from "child_process";
|
|
4888
|
-
import { readFileSync as
|
|
4889
|
-
import { join as
|
|
5050
|
+
import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
|
|
5051
|
+
import { join as join13 } from "path";
|
|
4890
5052
|
function parseMatchSpec(condition) {
|
|
4891
5053
|
if (!condition.startsWith("match_spec:")) return null;
|
|
4892
5054
|
try {
|
|
@@ -5365,10 +5527,10 @@ function shouldFail(findings, threshold) {
|
|
|
5365
5527
|
return findings.some((f) => order.indexOf(f.severity) >= thresholdIdx);
|
|
5366
5528
|
}
|
|
5367
5529
|
function readRepoDeps() {
|
|
5368
|
-
const pkgPath =
|
|
5369
|
-
if (!
|
|
5530
|
+
const pkgPath = join13(process.cwd(), "package.json");
|
|
5531
|
+
if (!existsSync13(pkgPath)) return {};
|
|
5370
5532
|
try {
|
|
5371
|
-
const pkg = JSON.parse(
|
|
5533
|
+
const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
|
|
5372
5534
|
return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
5373
5535
|
} catch {
|
|
5374
5536
|
return {};
|
|
@@ -5621,7 +5783,7 @@ async function updateCommand() {
|
|
|
5621
5783
|
var init_update = __esm({
|
|
5622
5784
|
"cli/commands/update.ts"() {
|
|
5623
5785
|
"use strict";
|
|
5624
|
-
|
|
5786
|
+
init_install2();
|
|
5625
5787
|
}
|
|
5626
5788
|
});
|
|
5627
5789
|
|
|
@@ -5630,12 +5792,24 @@ var disconnect_exports = {};
|
|
|
5630
5792
|
__export(disconnect_exports, {
|
|
5631
5793
|
disconnectCommand: () => disconnectCommand
|
|
5632
5794
|
});
|
|
5633
|
-
import { existsSync as
|
|
5634
|
-
import { homedir as
|
|
5635
|
-
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
|
+
}
|
|
5636
5809
|
function disconnectCommand(args2 = []) {
|
|
5637
5810
|
const purge = args2.includes("--purge");
|
|
5638
5811
|
console.log("Synkro disconnect starting...\n");
|
|
5812
|
+
tearDownLocalCC();
|
|
5639
5813
|
const agents = detectAgents();
|
|
5640
5814
|
let sawClaudeCode = false;
|
|
5641
5815
|
for (const agent of agents) {
|
|
@@ -5650,25 +5824,27 @@ function disconnectCommand(args2 = []) {
|
|
|
5650
5824
|
console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
|
|
5651
5825
|
}
|
|
5652
5826
|
if (purge) {
|
|
5653
|
-
if (
|
|
5654
|
-
rmSync(
|
|
5655
|
-
console.log(`\u2713 Removed ${
|
|
5827
|
+
if (existsSync14(SYNKRO_DIR6)) {
|
|
5828
|
+
rmSync(SYNKRO_DIR6, { recursive: true, force: true });
|
|
5829
|
+
console.log(`\u2713 Removed ${SYNKRO_DIR6}`);
|
|
5656
5830
|
} else {
|
|
5657
|
-
console.log(`\xB7 ${
|
|
5831
|
+
console.log(`\xB7 ${SYNKRO_DIR6} already gone, nothing to remove`);
|
|
5658
5832
|
}
|
|
5659
|
-
} else if (
|
|
5660
|
-
console.log(`Config preserved at ${
|
|
5833
|
+
} else if (existsSync14(SYNKRO_DIR6)) {
|
|
5834
|
+
console.log(`Config preserved at ${SYNKRO_DIR6}. Run with --purge to remove.`);
|
|
5661
5835
|
}
|
|
5662
5836
|
console.log("\nSynkro disconnected.");
|
|
5663
5837
|
}
|
|
5664
|
-
var
|
|
5838
|
+
var SYNKRO_DIR6;
|
|
5665
5839
|
var init_disconnect = __esm({
|
|
5666
5840
|
"cli/commands/disconnect.ts"() {
|
|
5667
5841
|
"use strict";
|
|
5668
5842
|
init_agentDetect();
|
|
5669
5843
|
init_ccHookConfig();
|
|
5670
5844
|
init_mcpConfig();
|
|
5671
|
-
|
|
5845
|
+
init_pueue();
|
|
5846
|
+
init_install();
|
|
5847
|
+
SYNKRO_DIR6 = join14(homedir12(), ".synkro");
|
|
5672
5848
|
}
|
|
5673
5849
|
});
|
|
5674
5850
|
|
|
@@ -5705,20 +5881,682 @@ var init_reinstall = __esm({
|
|
|
5705
5881
|
"cli/commands/reinstall.ts"() {
|
|
5706
5882
|
"use strict";
|
|
5707
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";
|
|
5708
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();
|
|
5709
6547
|
}
|
|
5710
6548
|
});
|
|
5711
6549
|
|
|
5712
6550
|
// cli/bootstrap.js
|
|
5713
|
-
import { readFileSync as
|
|
6551
|
+
import { readFileSync as readFileSync13, existsSync as existsSync17 } from "fs";
|
|
5714
6552
|
import { resolve } from "path";
|
|
5715
6553
|
var envCandidates = [
|
|
5716
6554
|
resolve(process.cwd(), ".env"),
|
|
5717
6555
|
resolve(process.env.HOME ?? "", ".synkro", "config.env")
|
|
5718
6556
|
];
|
|
5719
6557
|
for (const envPath of envCandidates) {
|
|
5720
|
-
if (!
|
|
5721
|
-
const envContent =
|
|
6558
|
+
if (!existsSync17(envPath)) continue;
|
|
6559
|
+
const envContent = readFileSync13(envPath, "utf-8");
|
|
5722
6560
|
for (const line of envContent.split("\n")) {
|
|
5723
6561
|
const trimmed = line.trim();
|
|
5724
6562
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -5732,7 +6570,7 @@ for (const envPath of envCandidates) {
|
|
|
5732
6570
|
var args = process.argv.slice(2);
|
|
5733
6571
|
var cmd = args[0] || "";
|
|
5734
6572
|
var subArgs = args.slice(1);
|
|
5735
|
-
function
|
|
6573
|
+
function printHelp2() {
|
|
5736
6574
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|
|
5737
6575
|
|
|
5738
6576
|
Usage:
|
|
@@ -5752,6 +6590,8 @@ Commands:
|
|
|
5752
6590
|
disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
|
|
5753
6591
|
uninstall Fully remove Synkro from this machine
|
|
5754
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)
|
|
5755
6595
|
help Show this message
|
|
5756
6596
|
|
|
5757
6597
|
Quick start:
|
|
@@ -5764,7 +6604,7 @@ Quick start:
|
|
|
5764
6604
|
async function main() {
|
|
5765
6605
|
switch (cmd) {
|
|
5766
6606
|
case "install": {
|
|
5767
|
-
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (
|
|
6607
|
+
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install2(), install_exports));
|
|
5768
6608
|
await installCommand2(parseArgs2(subArgs));
|
|
5769
6609
|
break;
|
|
5770
6610
|
}
|
|
@@ -5835,16 +6675,26 @@ async function main() {
|
|
|
5835
6675
|
await reinstallCommand2();
|
|
5836
6676
|
break;
|
|
5837
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
|
+
}
|
|
5838
6688
|
case "help":
|
|
5839
6689
|
case "--help":
|
|
5840
6690
|
case "-h":
|
|
5841
6691
|
case "": {
|
|
5842
|
-
|
|
6692
|
+
printHelp2();
|
|
5843
6693
|
break;
|
|
5844
6694
|
}
|
|
5845
6695
|
default: {
|
|
5846
6696
|
console.error(`Unknown command: ${cmd}`);
|
|
5847
|
-
|
|
6697
|
+
printHelp2();
|
|
5848
6698
|
process.exit(1);
|
|
5849
6699
|
}
|
|
5850
6700
|
}
|