@synkro-sh/cli 1.3.58 → 1.4.0

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