@synkro-sh/cli 1.3.59 → 1.4.1

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";
@@ -3839,15 +3303,618 @@ var init_setupGithub = __esm({
3839
3303
  }
3840
3304
  });
3841
3305
 
3306
+ // cli/installer/promptFetcher.ts
3307
+ import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
3308
+ import { homedir as homedir5 } from "os";
3309
+ import { join as join6 } from "path";
3310
+ function readCache() {
3311
+ if (!existsSync7(CACHE_PATH)) return null;
3312
+ try {
3313
+ return JSON.parse(readFileSync5(CACHE_PATH, "utf-8"));
3314
+ } catch {
3315
+ return null;
3316
+ }
3317
+ }
3318
+ function writeCache(entry) {
3319
+ mkdirSync5(join6(homedir5(), ".synkro", "prompts"), { recursive: true });
3320
+ writeFileSync5(CACHE_PATH, JSON.stringify(entry, null, 2), "utf-8");
3321
+ }
3322
+ function isCacheFresh(cache) {
3323
+ const ageMs = Date.now() - cache.fetched_at;
3324
+ const ttlMs = cache.ttl_hours * 60 * 60 * 1e3;
3325
+ return ageMs < ttlMs;
3326
+ }
3327
+ async function fetchJudgePrompts(opts) {
3328
+ if (!opts.forceRefresh) {
3329
+ const cache = readCache();
3330
+ if (cache && isCacheFresh(cache)) {
3331
+ return mapResponse(cache);
3332
+ }
3333
+ }
3334
+ const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/cli/judge-prompts`;
3335
+ const resp = await fetch(url, {
3336
+ method: "GET",
3337
+ headers: {
3338
+ "Authorization": `Bearer ${opts.jwt}`,
3339
+ "User-Agent": "synkro-cli/1.0"
3340
+ }
3341
+ });
3342
+ if (!resp.ok) {
3343
+ throw new Error(`Failed to fetch judge prompts: HTTP ${resp.status} ${resp.statusText}`);
3344
+ }
3345
+ const data = await resp.json();
3346
+ if (!data.edit_write_prompt) {
3347
+ throw new Error("Gateway returned empty edit_write_prompt");
3348
+ }
3349
+ writeCache({ ...data, fetched_at: Date.now() });
3350
+ return mapResponse(data);
3351
+ }
3352
+ function mapResponse(data) {
3353
+ return {
3354
+ editWritePrompt: data.edit_write_prompt,
3355
+ editAutofixAgentPrompt: data.edit_autofix_agent_prompt,
3356
+ editPrecheckAgentPrompt: data.edit_precheck_agent_prompt,
3357
+ graderPrimerBash: data.grader_primer_bash,
3358
+ graderPrimerEdit: data.grader_primer_edit,
3359
+ classificationPrompt: data.classification_prompt,
3360
+ intentClassifyPrompt: data.intent_classify_prompt,
3361
+ remediateIntentClassifyPrompt: data.remediate_intent_classify_prompt,
3362
+ version: data.version
3363
+ };
3364
+ }
3365
+ var CACHE_PATH;
3366
+ var init_promptFetcher = __esm({
3367
+ "cli/installer/promptFetcher.ts"() {
3368
+ "use strict";
3369
+ CACHE_PATH = join6(homedir5(), ".synkro", "prompts", "judge-prompts.json");
3370
+ }
3371
+ });
3372
+
3373
+ // cli/local-cc/channelSource.ts
3374
+ var CHANNEL_PLUGIN_SOURCE;
3375
+ var init_channelSource = __esm({
3376
+ "cli/local-cc/channelSource.ts"() {
3377
+ "use strict";
3378
+ CHANNEL_PLUGIN_SOURCE = `#!/usr/bin/env bun
3379
+ /**
3380
+ * Synkro local-CC channel plugin (auto-generated by \`synkro install\`).
3381
+ * DO NOT EDIT \u2014 your changes will be overwritten on next install.
3382
+ */
3383
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3384
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3385
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3386
+
3387
+ const PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || '8929', 10);
3388
+ const HOSTNAME = '127.0.0.1';
3389
+
3390
+ const REQUEST_TIMEOUT_MS = parseInt(process.env.SYNKRO_CHANNEL_TIMEOUT_MS || '120000', 10);
3391
+
3392
+ interface PendingRequest {
3393
+ resolve: (result: string) => void;
3394
+ reject: (err: Error) => void;
3395
+ timer: ReturnType<typeof setTimeout>;
3396
+ }
3397
+
3398
+ const pending = new Map<string, PendingRequest>();
3399
+ let nextRequestId = 1;
3400
+
3401
+ const mcp = new Server(
3402
+ { name: 'synkro-local', version: '0.1.0' },
3403
+ {
3404
+ capabilities: {
3405
+ experimental: { 'claude/channel': {} },
3406
+ tools: {},
3407
+ },
3408
+ instructions: [
3409
+ 'Synkro local inference channel.',
3410
+ 'Each <channel source="synkro-local" req_id="..." role="..."> event contains a',
3411
+ 'self-contained instruction block followed by the payload to evaluate. Treat it',
3412
+ 'as a fresh isolated request \u2014 IGNORE any prior conversation turns or context.',
3413
+ 'Do not call Read, Edit, Write, Bash, or any other tool. Do exactly one thing:',
3414
+ 'parse the request, produce the structured response described inside it, then',
3415
+ 'call the \\\`reply\\\` tool exactly once with the same req_id and the response',
3416
+ 'wrapped as the \\\`result\\\` argument (a string). Output no other text.',
3417
+ ].join(' '),
3418
+ },
3419
+ );
3420
+
3421
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
3422
+ tools: [{
3423
+ name: 'reply',
3424
+ description: 'Return the response for a Synkro local-inference request',
3425
+ inputSchema: {
3426
+ type: 'object',
3427
+ properties: {
3428
+ req_id: { type: 'string', description: 'The req_id from the channel event being answered' },
3429
+ result: { type: 'string', description: 'The response text. For grading/classification roles, the JSON-tagged verdict the role requested.' },
3430
+ },
3431
+ required: ['req_id', 'result'],
3432
+ },
3433
+ }],
3434
+ }));
3435
+
3436
+ mcp.setRequestHandler(CallToolRequestSchema, async req => {
3437
+ if (req.params.name !== 'reply') {
3438
+ throw new Error('unknown tool: ' + req.params.name);
3439
+ }
3440
+ const args = req.params.arguments as { req_id?: string; result?: string };
3441
+ const reqId = String(args.req_id ?? '');
3442
+ const result = String(args.result ?? '');
3443
+ const p = pending.get(reqId);
3444
+ if (p) {
3445
+ clearTimeout(p.timer);
3446
+ pending.delete(reqId);
3447
+ p.resolve(result);
3448
+ return { content: [{ type: 'text', text: 'ok' }] };
3449
+ }
3450
+ return { content: [{ type: 'text', text: 'unknown req_id (likely already timed out)' }] };
3451
+ });
3452
+
3453
+ // Bind the listener BEFORE awaiting mcp.connect \u2014 Bun.serve is synchronous
3454
+ // and must run on the script's first tick, otherwise the stdio transport's
3455
+ // read loop can starve the serve setup.
3456
+ Bun.serve({
3457
+ port: PORT,
3458
+ hostname: HOSTNAME,
3459
+ idleTimeout: 0,
3460
+ async fetch(req) {
3461
+ const url = new URL(req.url);
3462
+ if (url.pathname === '/healthz') {
3463
+ return new Response(JSON.stringify({ ok: true, pending: pending.size }), {
3464
+ headers: { 'Content-Type': 'application/json' },
3465
+ });
3466
+ }
3467
+ if (req.method !== 'POST' || url.pathname !== '/submit') {
3468
+ return new Response('not found', { status: 404 });
3469
+ }
3470
+ let body: { role?: string; content?: string };
3471
+ try {
3472
+ body = await req.json() as typeof body;
3473
+ } catch {
3474
+ return new Response('invalid json', { status: 400 });
3475
+ }
3476
+ const role = String(body.role ?? '');
3477
+ const content = String(body.content ?? '');
3478
+ if (!role || !content) {
3479
+ return new Response('missing role/content', { status: 400 });
3480
+ }
3481
+ const reqId = 'r' + (nextRequestId++) + Date.now().toString(36);
3482
+ const result = await new Promise<string>((resolve, reject) => {
3483
+ const timer = setTimeout(() => {
3484
+ pending.delete(reqId);
3485
+ reject(new Error('timeout waiting for reply (' + REQUEST_TIMEOUT_MS + 'ms)'));
3486
+ }, REQUEST_TIMEOUT_MS);
3487
+ pending.set(reqId, { resolve, reject, timer });
3488
+ mcp.notification({
3489
+ method: 'notifications/claude/channel',
3490
+ params: {
3491
+ content,
3492
+ meta: { req_id: reqId, role },
3493
+ },
3494
+ }).catch(err => {
3495
+ clearTimeout(timer);
3496
+ pending.delete(reqId);
3497
+ reject(err instanceof Error ? err : new Error(String(err)));
3498
+ });
3499
+ }).catch(err => {
3500
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
3501
+ });
3502
+ if (typeof result === 'string' && result.startsWith('{"error":')) {
3503
+ return new Response(result, { status: 504, headers: { 'Content-Type': 'application/json' } });
3504
+ }
3505
+ return new Response(JSON.stringify({ result }), {
3506
+ headers: { 'Content-Type': 'application/json' },
3507
+ });
3508
+ },
3509
+ });
3510
+
3511
+ process.on('SIGTERM', () => process.exit(0));
3512
+ process.on('SIGINT', () => process.exit(0));
3513
+
3514
+ // MCP stdio handshake last. The transport's read loop keeps the process
3515
+ // alive; the TCP listener is already bound at this point so the CLI can
3516
+ // hit it as soon as Claude finishes its end of the handshake.
3517
+ await mcp.connect(new StdioServerTransport());
3518
+ `;
3519
+ }
3520
+ });
3521
+
3522
+ // cli/local-cc/install.ts
3523
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readFileSync as readFileSync6, chmodSync, copyFileSync, renameSync as renameSync3, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
3524
+ import { join as join7 } from "path";
3525
+ import { homedir as homedir6 } from "os";
3526
+ import { spawnSync } from "child_process";
3527
+ function writePluginFiles() {
3528
+ mkdirSync6(SESSION_DIR, { recursive: true });
3529
+ mkdirSync6(PLUGIN_SETTINGS_DIR, { recursive: true });
3530
+ writeFileSync6(PLUGIN_PATH, CHANNEL_PLUGIN_SOURCE, "utf-8");
3531
+ chmodSync(PLUGIN_PATH, 493);
3532
+ writeFileSync6(PLUGIN_PKG_PATH, PLUGIN_PACKAGE_JSON, "utf-8");
3533
+ writeFileSync6(
3534
+ PLUGIN_SETTINGS_PATH,
3535
+ JSON.stringify({
3536
+ fastMode: true,
3537
+ // Pre-approve the project-local synkro-local MCP server so claude doesn't
3538
+ // block on a consent prompt at startup. Lives in the PROJECT settings so
3539
+ // it's still picked up under --setting-sources project,local (which
3540
+ // skips user settings to avoid synkro-hook recursion in the grader).
3541
+ enabledMcpjsonServers: ["synkro-local"]
3542
+ }, null, 2) + "\n",
3543
+ "utf-8"
3544
+ );
3545
+ writeFileSync6(RUN_SCRIPT_PATH, RUN_SCRIPT_SOURCE, "utf-8");
3546
+ chmodSync(RUN_SCRIPT_PATH, 493);
3547
+ }
3548
+ function runBunInstall() {
3549
+ const r = spawnSync("bun", ["install", "--silent"], {
3550
+ cwd: SESSION_DIR,
3551
+ encoding: "utf-8",
3552
+ timeout: 12e4
3553
+ });
3554
+ if (r.status !== 0) {
3555
+ throw new LocalCCInstallError(
3556
+ `bun install failed in ${SESSION_DIR}: ${r.stderr || r.stdout || "unknown"}`
3557
+ );
3558
+ }
3559
+ }
3560
+ function safelyMutateClaudeJson(mutator) {
3561
+ if (!existsSync8(CLAUDE_JSON_PATH)) {
3562
+ return;
3563
+ }
3564
+ const originalText = readFileSync6(CLAUDE_JSON_PATH, "utf-8");
3565
+ let parsed;
3566
+ try {
3567
+ parsed = JSON.parse(originalText);
3568
+ } catch (err) {
3569
+ throw new LocalCCInstallError(
3570
+ `refusing to modify malformed ${CLAUDE_JSON_PATH}: ${err.message}. Please fix the JSON manually before retrying.`,
3571
+ err
3572
+ );
3573
+ }
3574
+ const originalKeys = new Set(Object.keys(parsed));
3575
+ const dirty = mutator(parsed);
3576
+ if (!dirty) return;
3577
+ for (const k of originalKeys) {
3578
+ if (!(k in parsed)) {
3579
+ throw new LocalCCInstallError(
3580
+ `refusing to write ${CLAUDE_JSON_PATH}: mutator dropped top-level key "${k}". This is a bug \u2014 please report.`
3581
+ );
3582
+ }
3583
+ }
3584
+ const newText = JSON.stringify(parsed, null, 2) + "\n";
3585
+ try {
3586
+ JSON.parse(newText);
3587
+ } catch (err) {
3588
+ throw new LocalCCInstallError(
3589
+ `refusing to write ${CLAUDE_JSON_PATH}: serialized result is not valid JSON. This is a bug \u2014 please report.`,
3590
+ err
3591
+ );
3592
+ }
3593
+ copyFileSync(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
3594
+ const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
3595
+ try {
3596
+ writeFileSync6(tmpPath, newText, "utf-8");
3597
+ const fd = openSync(tmpPath, "r");
3598
+ try {
3599
+ fsyncSync(fd);
3600
+ } finally {
3601
+ closeSync(fd);
3602
+ }
3603
+ renameSync3(tmpPath, CLAUDE_JSON_PATH);
3604
+ } catch (err) {
3605
+ try {
3606
+ unlinkSync4(tmpPath);
3607
+ } catch {
3608
+ }
3609
+ try {
3610
+ copyFileSync(CLAUDE_JSON_BACKUP_PATH, CLAUDE_JSON_PATH);
3611
+ } catch {
3612
+ }
3613
+ throw new LocalCCInstallError(
3614
+ `failed to write ${CLAUDE_JSON_PATH}: ${err.message}. Backup at ${CLAUDE_JSON_BACKUP_PATH} preserves the prior state.`,
3615
+ err
3616
+ );
3617
+ }
3618
+ }
3619
+ function writeProjectMcpJson() {
3620
+ const mcp = {
3621
+ mcpServers: {
3622
+ [MCP_SERVER_NAME]: {
3623
+ command: "bun",
3624
+ args: [PLUGIN_PATH]
3625
+ }
3626
+ }
3627
+ };
3628
+ writeFileSync6(PROJECT_MCP_PATH, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
3629
+ }
3630
+ function patchClaudeJson() {
3631
+ safelyMutateClaudeJson((parsed) => {
3632
+ let dirty = false;
3633
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object" && parsed.mcpServers[MCP_SERVER_NAME]) {
3634
+ delete parsed.mcpServers[MCP_SERVER_NAME];
3635
+ dirty = true;
3636
+ }
3637
+ if (!parsed.projects || typeof parsed.projects !== "object") {
3638
+ parsed.projects = {};
3639
+ }
3640
+ const projects = parsed.projects;
3641
+ const existing = projects[SESSION_DIR] && typeof projects[SESSION_DIR] === "object" ? projects[SESSION_DIR] : {};
3642
+ const wantEnabled = Array.from(/* @__PURE__ */ new Set([
3643
+ ...existing.enabledMcpjsonServers ?? [],
3644
+ MCP_SERVER_NAME
3645
+ ]));
3646
+ const next = {
3647
+ ...existing,
3648
+ hasTrustDialogAccepted: true,
3649
+ hasCompletedProjectOnboarding: true,
3650
+ enabledMcpjsonServers: wantEnabled
3651
+ };
3652
+ if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
3653
+ projects[SESSION_DIR] = next;
3654
+ dirty = true;
3655
+ }
3656
+ return dirty;
3657
+ });
3658
+ }
3659
+ function installLocalCC() {
3660
+ const bunCheck = spawnSync("bun", ["--version"], { encoding: "utf-8" });
3661
+ if (bunCheck.status !== 0) {
3662
+ throw new LocalCCInstallError("bun is required for the local-CC channel plugin. Install Bun (https://bun.sh) and retry.");
3663
+ }
3664
+ writePluginFiles();
3665
+ runBunInstall();
3666
+ writeProjectMcpJson();
3667
+ patchClaudeJson();
3668
+ return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
3669
+ }
3670
+ function uninstallLocalCC() {
3671
+ safelyMutateClaudeJson((parsed) => {
3672
+ let dirty = false;
3673
+ if (parsed.mcpServers && parsed.mcpServers[MCP_SERVER_NAME]) {
3674
+ delete parsed.mcpServers[MCP_SERVER_NAME];
3675
+ dirty = true;
3676
+ }
3677
+ if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[SESSION_DIR]) {
3678
+ delete parsed.projects[SESSION_DIR];
3679
+ dirty = true;
3680
+ }
3681
+ return dirty;
3682
+ });
3683
+ }
3684
+ 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;
3685
+ var init_install = __esm({
3686
+ "cli/local-cc/install.ts"() {
3687
+ "use strict";
3688
+ init_channelSource();
3689
+ CLAUDE_JSON_BACKUP_PATH = join7(homedir6(), ".claude.json.synkro-bak");
3690
+ SESSION_DIR = join7(homedir6(), ".synkro", "cc_sessions");
3691
+ PLUGIN_PATH = join7(SESSION_DIR, "synkro-channel.ts");
3692
+ PLUGIN_PKG_PATH = join7(SESSION_DIR, "package.json");
3693
+ PLUGIN_SETTINGS_DIR = join7(SESSION_DIR, ".claude");
3694
+ PLUGIN_SETTINGS_PATH = join7(PLUGIN_SETTINGS_DIR, "settings.json");
3695
+ PROJECT_MCP_PATH = join7(SESSION_DIR, ".mcp.json");
3696
+ CLAUDE_JSON_PATH = join7(homedir6(), ".claude.json");
3697
+ RUN_SCRIPT_PATH = join7(SESSION_DIR, "run-claude.sh");
3698
+ TMUX_SESSION_NAME = "synkro-local-cc";
3699
+ RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
3700
+ # Auto-generated by \`synkro install\`. Do not edit.
3701
+ set -uo pipefail
3702
+
3703
+ SESSION=${TMUX_SESSION_NAME}
3704
+
3705
+ # Kill any previous session so restarts come up clean.
3706
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
3707
+
3708
+ # Start claude inside a detached tmux session so it has a real pty.
3709
+ #
3710
+ # --setting-sources project,local: skip ~/.claude/settings.json so the
3711
+ # synkro PreToolUse/PostToolUse hooks installed there don't load. Without
3712
+ # this, the grader's own tool calls would re-trigger synkro grading,
3713
+ # causing recursion / deadlock with the same channel session.
3714
+ tmux new-session -d -s "$SESSION" \\
3715
+ "claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local"
3716
+
3717
+ # Block on the tmux session so pueue's task lifetime tracks claude's.
3718
+ while tmux has-session -t "$SESSION" 2>/dev/null; do
3719
+ sleep 5
3720
+ done
3721
+ `;
3722
+ MCP_SERVER_NAME = "synkro-local";
3723
+ PLUGIN_PACKAGE_JSON = JSON.stringify(
3724
+ {
3725
+ name: "synkro-local-channel",
3726
+ private: true,
3727
+ version: "0.1.0",
3728
+ type: "module",
3729
+ dependencies: {
3730
+ "@modelcontextprotocol/sdk": "^1.0.0"
3731
+ }
3732
+ },
3733
+ null,
3734
+ 2
3735
+ ) + "\n";
3736
+ LocalCCInstallError = class extends Error {
3737
+ constructor(message, cause) {
3738
+ super(message);
3739
+ this.cause = cause;
3740
+ this.name = "LocalCCInstallError";
3741
+ }
3742
+ cause;
3743
+ };
3744
+ }
3745
+ });
3746
+
3747
+ // cli/local-cc/pueue.ts
3748
+ import { execFileSync, spawnSync as spawnSync2 } from "child_process";
3749
+ import { homedir as homedir7 } from "os";
3750
+ import { join as join8 } from "path";
3751
+ import { connect } from "net";
3752
+ function pueueAvailable() {
3753
+ const r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
3754
+ if (r.status !== 0) {
3755
+ throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
3756
+ }
3757
+ }
3758
+ function statusJson() {
3759
+ pueueAvailable();
3760
+ const r = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8" });
3761
+ if (r.status !== 0) {
3762
+ throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
3763
+ }
3764
+ try {
3765
+ return JSON.parse(r.stdout);
3766
+ } catch (err) {
3767
+ throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
3768
+ }
3769
+ }
3770
+ function statusName(s) {
3771
+ if (typeof s === "string") return s;
3772
+ if (s && typeof s === "object") {
3773
+ if ("Running" in s) return "Running";
3774
+ if ("Done" in s) {
3775
+ const result = s.Done?.result;
3776
+ if (typeof result === "string") return `Done (${result})`;
3777
+ if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
3778
+ return "Done";
3779
+ }
3780
+ return Object.keys(s)[0] ?? "unknown";
3781
+ }
3782
+ return "unknown";
3783
+ }
3784
+ function findTask() {
3785
+ const data = statusJson();
3786
+ for (const [id, t] of Object.entries(data.tasks)) {
3787
+ if (t.label === TASK_LABEL) {
3788
+ return {
3789
+ id: Number(id),
3790
+ label: t.label,
3791
+ status: statusName(t.status),
3792
+ command: t.command,
3793
+ cwd: t.path
3794
+ };
3795
+ }
3796
+ }
3797
+ return null;
3798
+ }
3799
+ function startTask(opts = {}) {
3800
+ const cwd = opts.cwd ?? SESSION_DIR2;
3801
+ const existing = findTask();
3802
+ if (existing) {
3803
+ spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
3804
+ }
3805
+ const runScript = join8(cwd, "run-claude.sh");
3806
+ const args2 = [
3807
+ "add",
3808
+ "--label",
3809
+ TASK_LABEL,
3810
+ "--working-directory",
3811
+ cwd,
3812
+ "--",
3813
+ "bash",
3814
+ runScript
3815
+ ];
3816
+ const r = spawnSync2("pueue", args2, { encoding: "utf-8" });
3817
+ if (r.status !== 0) {
3818
+ throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
3819
+ }
3820
+ const created = findTask();
3821
+ if (!created) {
3822
+ throw new PueueError(`pueue add succeeded but no task with label ${TASK_LABEL} found`);
3823
+ }
3824
+ return created;
3825
+ }
3826
+ function stopTask() {
3827
+ spawnSync2("tmux", ["kill-session", "-t", TMUX_SESSION], { encoding: "utf-8" });
3828
+ const t = findTask();
3829
+ if (!t) return;
3830
+ spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
3831
+ spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
3832
+ }
3833
+ function tailLogs(lines = 80) {
3834
+ const t = findTask();
3835
+ if (!t) return "(no synkro local-cc task)";
3836
+ const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
3837
+ return r.stdout || r.stderr || "(no output)";
3838
+ }
3839
+ function ensureRunning(opts = {}) {
3840
+ const t = findTask();
3841
+ if (t && t.status === "Running") return t;
3842
+ return startTask(opts);
3843
+ }
3844
+ function probePort(host, port, timeoutMs = 500) {
3845
+ return new Promise((resolve2) => {
3846
+ const sock = connect(port, host);
3847
+ const done = (ok) => {
3848
+ try {
3849
+ sock.destroy();
3850
+ } catch {
3851
+ }
3852
+ resolve2(ok);
3853
+ };
3854
+ sock.once("connect", () => done(true));
3855
+ sock.once("error", () => done(false));
3856
+ sock.setTimeout(timeoutMs, () => done(false));
3857
+ });
3858
+ }
3859
+ function tmuxKickEnter() {
3860
+ spawnSync2("tmux", ["send-keys", "-t", TMUX_SESSION, "Enter"], { encoding: "utf-8" });
3861
+ }
3862
+ async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1") {
3863
+ const deadline = Date.now() + timeoutMs;
3864
+ while (Date.now() < deadline) {
3865
+ if (await probePort(host, port)) return true;
3866
+ tmuxKickEnter();
3867
+ await new Promise((r) => setTimeout(r, 1e3));
3868
+ }
3869
+ return probePort(host, port);
3870
+ }
3871
+ function assertPueueInstalled() {
3872
+ pueueAvailable();
3873
+ try {
3874
+ statusJson();
3875
+ } catch (err) {
3876
+ throw new PueueError(`pueue daemon not reachable: ${err.message}`);
3877
+ }
3878
+ }
3879
+ function assertClaudeInstalled() {
3880
+ const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
3881
+ if (r.status !== 0) {
3882
+ throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
3883
+ }
3884
+ }
3885
+ function assertTmuxInstalled() {
3886
+ const r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
3887
+ if (r.status !== 0) {
3888
+ throw new PueueError("tmux not found on PATH. Install tmux (brew install tmux) and retry.");
3889
+ }
3890
+ }
3891
+ var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, PueueError;
3892
+ var init_pueue = __esm({
3893
+ "cli/local-cc/pueue.ts"() {
3894
+ "use strict";
3895
+ TASK_LABEL = "synkro-local-cc";
3896
+ TMUX_SESSION = "synkro-local-cc";
3897
+ SESSION_DIR2 = join8(homedir7(), ".synkro", "cc_sessions");
3898
+ PueueError = class extends Error {
3899
+ constructor(message, cause) {
3900
+ super(message);
3901
+ this.cause = cause;
3902
+ this.name = "PueueError";
3903
+ }
3904
+ cause;
3905
+ };
3906
+ }
3907
+ });
3908
+
3842
3909
  // cli/commands/install.ts
3843
3910
  var install_exports = {};
3844
3911
  __export(install_exports, {
3845
3912
  installCommand: () => installCommand,
3846
3913
  parseArgs: () => parseArgs
3847
3914
  });
3848
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync5, readdirSync } from "fs";
3849
- import { homedir as homedir5 } from "os";
3850
- import { join as join6 } from "path";
3915
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
3916
+ import { homedir as homedir8 } from "os";
3917
+ import { join as join9 } from "path";
3851
3918
  import { execSync as execSync5 } from "child_process";
3852
3919
  import { createInterface as createInterface3 } from "readline";
3853
3920
  function sanitizeGatewayCandidate(raw) {
@@ -3884,43 +3951,33 @@ async function promptTranscriptConsent() {
3884
3951
  });
3885
3952
  }
3886
3953
  function ensureSynkroDir() {
3887
- mkdirSync5(SYNKRO_DIR2, { recursive: true });
3888
- mkdirSync5(HOOKS_DIR, { recursive: true });
3889
- mkdirSync5(BIN_DIR, { recursive: true });
3890
- mkdirSync5(OFFSETS_DIR, { recursive: true });
3891
- }
3892
- function writeGraderDaemon() {
3893
- writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
3894
- chmodSync(GRADER_DAEMON_PATH, 493);
3895
- for (const p of [GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH]) {
3896
- try {
3897
- __require("fs").unlinkSync(p);
3898
- } catch {
3899
- }
3900
- }
3954
+ mkdirSync7(SYNKRO_DIR2, { recursive: true });
3955
+ mkdirSync7(HOOKS_DIR, { recursive: true });
3956
+ mkdirSync7(BIN_DIR, { recursive: true });
3957
+ mkdirSync7(OFFSETS_DIR, { recursive: true });
3901
3958
  }
3902
3959
  function writeHookScripts() {
3903
- const bashScriptPath = join6(HOOKS_DIR, "cc-bash-judge.sh");
3904
- const bashFollowupScriptPath = join6(HOOKS_DIR, "cc-bash-followup.sh");
3905
- const editCaptureScriptPath = join6(HOOKS_DIR, "cc-edit-capture.sh");
3906
- const editPrecheckScriptPath = join6(HOOKS_DIR, "cc-edit-precheck.sh");
3907
- const stopSummaryScriptPath = join6(HOOKS_DIR, "cc-stop-summary.sh");
3908
- const sessionStartScriptPath = join6(HOOKS_DIR, "cc-session-start.sh");
3909
- const transcriptSyncScriptPath = join6(HOOKS_DIR, "cc-transcript-sync.sh");
3910
- writeFileSync5(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
3911
- writeFileSync5(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
3912
- writeFileSync5(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
3913
- writeFileSync5(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
3914
- writeFileSync5(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
3915
- writeFileSync5(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
3916
- writeFileSync5(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
3917
- chmodSync(bashScriptPath, 493);
3918
- chmodSync(bashFollowupScriptPath, 493);
3919
- chmodSync(editCaptureScriptPath, 493);
3920
- chmodSync(editPrecheckScriptPath, 493);
3921
- chmodSync(stopSummaryScriptPath, 493);
3922
- chmodSync(sessionStartScriptPath, 493);
3923
- chmodSync(transcriptSyncScriptPath, 493);
3960
+ const bashScriptPath = join9(HOOKS_DIR, "cc-bash-judge.sh");
3961
+ const bashFollowupScriptPath = join9(HOOKS_DIR, "cc-bash-followup.sh");
3962
+ const editCaptureScriptPath = join9(HOOKS_DIR, "cc-edit-capture.sh");
3963
+ const editPrecheckScriptPath = join9(HOOKS_DIR, "cc-edit-precheck.sh");
3964
+ const stopSummaryScriptPath = join9(HOOKS_DIR, "cc-stop-summary.sh");
3965
+ const sessionStartScriptPath = join9(HOOKS_DIR, "cc-session-start.sh");
3966
+ const transcriptSyncScriptPath = join9(HOOKS_DIR, "cc-transcript-sync.sh");
3967
+ writeFileSync7(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
3968
+ writeFileSync7(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
3969
+ writeFileSync7(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
3970
+ writeFileSync7(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
3971
+ writeFileSync7(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
3972
+ writeFileSync7(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
3973
+ writeFileSync7(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
3974
+ chmodSync2(bashScriptPath, 493);
3975
+ chmodSync2(bashFollowupScriptPath, 493);
3976
+ chmodSync2(editCaptureScriptPath, 493);
3977
+ chmodSync2(editPrecheckScriptPath, 493);
3978
+ chmodSync2(stopSummaryScriptPath, 493);
3979
+ chmodSync2(sessionStartScriptPath, 493);
3980
+ chmodSync2(transcriptSyncScriptPath, 493);
3924
3981
  return {
3925
3982
  bashScript: bashScriptPath,
3926
3983
  bashFollowupScript: bashFollowupScriptPath,
@@ -3938,14 +3995,20 @@ function sanitizeConfigValue(raw, maxLen = 256) {
3938
3995
  function shellQuoteSingle(value) {
3939
3996
  return `'${value.replace(/'/g, "'\\''")}'`;
3940
3997
  }
3998
+ function resolveSynkroBundle() {
3999
+ const scriptPath = process.argv[1];
4000
+ if (scriptPath && existsSync9(scriptPath)) return scriptPath;
4001
+ return null;
4002
+ }
3941
4003
  function writeConfigEnv(opts) {
3942
- const credsPath = join6(SYNKRO_DIR2, "credentials.json");
4004
+ const credsPath = join9(SYNKRO_DIR2, "credentials.json");
3943
4005
  const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
3944
4006
  const safeUserId = sanitizeConfigValue(opts.userId);
3945
4007
  const safeOrgId = sanitizeConfigValue(opts.orgId);
3946
4008
  const safeEmail = sanitizeConfigValue(opts.email);
3947
4009
  const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
3948
4010
  const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
4011
+ const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
3949
4012
  const lines = [
3950
4013
  "# Synkro CLI config (managed by synkro install)",
3951
4014
  "# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
@@ -3954,17 +4017,19 @@ function writeConfigEnv(opts) {
3954
4017
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3955
4018
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3956
4019
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3957
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.59")}`
4020
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.1")}`
3958
4021
  ];
4022
+ if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
3959
4023
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3960
4024
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
3961
4025
  if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
3962
4026
  if (opts.transcriptConsent !== void 0) {
3963
4027
  lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
3964
4028
  }
4029
+ lines.push(`SYNKRO_LOCAL_INFERENCE=${shellQuoteSingle(opts.localInference ? "yes" : "no")}`);
3965
4030
  lines.push("");
3966
- writeFileSync5(CONFIG_PATH2, lines.join("\n"), "utf-8");
3967
- chmodSync(CONFIG_PATH2, 384);
4031
+ writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
4032
+ chmodSync2(CONFIG_PATH2, 384);
3968
4033
  }
3969
4034
  function collectLocalMetadata() {
3970
4035
  const meta = { platform: process.platform };
@@ -3984,16 +4049,16 @@ function collectLocalMetadata() {
3984
4049
  meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
3985
4050
  } catch {
3986
4051
  }
3987
- const claudeDir = join6(homedir5(), ".claude");
4052
+ const claudeDir = join9(homedir8(), ".claude");
3988
4053
  try {
3989
- const settings = JSON.parse(readFileSync5(join6(claudeDir, "settings.json"), "utf-8"));
4054
+ const settings = JSON.parse(readFileSync7(join9(claudeDir, "settings.json"), "utf-8"));
3990
4055
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
3991
4056
  if (plugins.length) meta.enabled_plugins = plugins;
3992
4057
  if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
3993
4058
  } catch {
3994
4059
  }
3995
4060
  try {
3996
- const mcpCache = JSON.parse(readFileSync5(join6(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
4061
+ const mcpCache = JSON.parse(readFileSync7(join9(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
3997
4062
  const mcpNames = Object.keys(mcpCache);
3998
4063
  if (mcpNames.length) meta.mcp_servers = mcpNames;
3999
4064
  } catch {
@@ -4005,10 +4070,10 @@ function collectLocalMetadata() {
4005
4070
  } catch {
4006
4071
  }
4007
4072
  try {
4008
- const sessionsDir = join6(claudeDir, "sessions");
4073
+ const sessionsDir = join9(claudeDir, "sessions");
4009
4074
  const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
4010
4075
  for (const f of files) {
4011
- const s = JSON.parse(readFileSync5(join6(sessionsDir, f), "utf-8"));
4076
+ const s = JSON.parse(readFileSync7(join9(sessionsDir, f), "utf-8"));
4012
4077
  if (s.version) {
4013
4078
  meta.cc_version = meta.cc_version || s.version;
4014
4079
  break;
@@ -4023,7 +4088,7 @@ async function fetchUserProfile(gatewayUrl, token) {
4023
4088
  const resp = await fetch(`${gatewayUrl}/api/v1/cli/me`, {
4024
4089
  headers: { "Authorization": `Bearer ${token}` }
4025
4090
  });
4026
- if (!resp.ok) return { tier: "pro", inference: "fast" };
4091
+ if (!resp.ok) return { tier: "pro", inference: "fast", localInference: false };
4027
4092
  const data = await resp.json();
4028
4093
  const meta = collectLocalMetadata();
4029
4094
  fetch(`${gatewayUrl}/api/v1/cli/me`, {
@@ -4034,10 +4099,11 @@ async function fetchUserProfile(gatewayUrl, token) {
4034
4099
  });
4035
4100
  return {
4036
4101
  tier: data.plan_tier ?? "pro",
4037
- inference: data.fast_inference ? "fast" : "standard"
4102
+ inference: data.fast_inference ? "fast" : "standard",
4103
+ localInference: !!data.local_inference
4038
4104
  };
4039
4105
  } catch {
4040
- return { tier: "pro", inference: "fast" };
4106
+ return { tier: "pro", inference: "fast", localInference: false };
4041
4107
  }
4042
4108
  }
4043
4109
  function assertGatewayAllowed(gatewayUrl) {
@@ -4063,19 +4129,19 @@ function assertGatewayAllowed(gatewayUrl) {
4063
4129
  }
4064
4130
  function isAlreadyInstalled() {
4065
4131
  const requiredScripts = [
4066
- join6(HOOKS_DIR, "cc-bash-judge.sh"),
4067
- join6(HOOKS_DIR, "cc-bash-followup.sh"),
4068
- join6(HOOKS_DIR, "cc-edit-precheck.sh"),
4069
- join6(HOOKS_DIR, "cc-edit-capture.sh"),
4070
- join6(HOOKS_DIR, "cc-stop-summary.sh"),
4071
- join6(HOOKS_DIR, "cc-session-start.sh")
4132
+ join9(HOOKS_DIR, "cc-bash-judge.sh"),
4133
+ join9(HOOKS_DIR, "cc-bash-followup.sh"),
4134
+ join9(HOOKS_DIR, "cc-edit-precheck.sh"),
4135
+ join9(HOOKS_DIR, "cc-edit-capture.sh"),
4136
+ join9(HOOKS_DIR, "cc-stop-summary.sh"),
4137
+ join9(HOOKS_DIR, "cc-session-start.sh")
4072
4138
  ];
4073
- if (!requiredScripts.every((p) => existsSync7(p))) return false;
4074
- if (!existsSync7(CONFIG_PATH2)) return false;
4075
- const settingsPath = join6(homedir5(), ".claude", "settings.json");
4076
- if (!existsSync7(settingsPath)) return false;
4139
+ if (!requiredScripts.every((p) => existsSync9(p))) return false;
4140
+ if (!existsSync9(CONFIG_PATH2)) return false;
4141
+ const settingsPath = join9(homedir8(), ".claude", "settings.json");
4142
+ if (!existsSync9(settingsPath)) return false;
4077
4143
  try {
4078
- const settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
4144
+ const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
4079
4145
  const hooks = settings?.hooks;
4080
4146
  if (!hooks || typeof hooks !== "object") return false;
4081
4147
  const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
@@ -4182,23 +4248,17 @@ async function installCommand(opts = {}) {
4182
4248
  console.log(` ${scripts.sessionStartScript}`);
4183
4249
  console.log(` ${scripts.transcriptSyncScript}
4184
4250
  `);
4185
- writeGraderDaemon();
4186
4251
  for (const mode of ["edit", "bash"]) {
4187
- const pidFile = join6(SYNKRO_DIR2, "daemon", mode, "daemon.pid");
4252
+ const pidFile = join9(SYNKRO_DIR2, "daemon", mode, "daemon.pid");
4188
4253
  try {
4189
- const pid = parseInt(readFileSync5(pidFile, "utf-8").trim(), 10);
4254
+ const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
4190
4255
  if (pid > 0) {
4191
4256
  process.kill(pid, "SIGTERM");
4192
- console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
4257
+ console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
4193
4258
  }
4194
4259
  } catch {
4195
4260
  }
4196
4261
  }
4197
- console.log("Wrote local-tier grader daemon:");
4198
- console.log(` ${GRADER_DAEMON_PATH}`);
4199
- console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
4200
- console.log(` ${GRADER_PRIMER_BASH_PATH}
4201
- `);
4202
4262
  let transcriptConsent = true;
4203
4263
  if (process.stdin.isTTY) {
4204
4264
  transcriptConsent = await promptTranscriptConsent();
@@ -4264,10 +4324,34 @@ async function installCommand(opts = {}) {
4264
4324
  } catch {
4265
4325
  }
4266
4326
  const profile = await fetchUserProfile(gatewayUrl, token);
4267
- writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, transcriptConsent });
4327
+ const synkroBundle = resolveSynkroBundle();
4328
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference });
4268
4329
  console.log(`Wrote config to ${CONFIG_PATH2}`);
4269
- console.log(` inference: ${profile.inference} (server-side grading)
4330
+ console.log(` inference: ${profile.inference} (server-side grading)`);
4331
+ if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
4332
+ if (synkroBundle) console.log(` SYNKRO_CLI_BIN=${synkroBundle}`);
4333
+ else console.warn(" \u26A0 Could not resolve synkro bundle path; hooks will fall back to PATH lookup of `synkro`.");
4334
+ try {
4335
+ const prompts = await fetchJudgePrompts({ gatewayUrl, jwt: token });
4336
+ console.log(` prompts: ${prompts.version} (cached)`);
4337
+ } catch (err) {
4338
+ console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
4339
+ }
4340
+ console.log();
4341
+ if (profile.localInference) {
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}
4270
4349
  `);
4350
+ } catch (err) {
4351
+ console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
4352
+ console.warn(" Set gradingProvider in your inference settings and re-run install.\n");
4353
+ }
4354
+ }
4271
4355
  if (transcriptConsent) {
4272
4356
  try {
4273
4357
  const repo = detectGitRepo2();
@@ -4314,17 +4398,17 @@ function detectGitRepo2() {
4314
4398
  function getClaudeProjectsFolder() {
4315
4399
  const cwd = process.cwd();
4316
4400
  const sanitized = "-" + cwd.replace(/\//g, "-");
4317
- const projectsDir = join6(homedir5(), ".claude", "projects", sanitized);
4318
- return existsSync7(projectsDir) ? projectsDir : null;
4401
+ const projectsDir = join9(homedir8(), ".claude", "projects", sanitized);
4402
+ return existsSync9(projectsDir) ? projectsDir : null;
4319
4403
  }
4320
4404
  function extractSessionInsights(projectsDir) {
4321
4405
  const insights = [];
4322
4406
  const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
4323
4407
  for (const file of files) {
4324
4408
  const sessionId = file.replace(".jsonl", "");
4325
- const filePath = join6(projectsDir, file);
4409
+ const filePath = join9(projectsDir, file);
4326
4410
  try {
4327
- const content = readFileSync5(filePath, "utf-8");
4411
+ const content = readFileSync7(filePath, "utf-8");
4328
4412
  const lines = content.split("\n").filter(Boolean);
4329
4413
  for (let i = 0; i < lines.length; i++) {
4330
4414
  try {
@@ -4400,7 +4484,7 @@ function extractTextContent(content) {
4400
4484
  return "";
4401
4485
  }
4402
4486
  function parseTranscriptFile(filePath) {
4403
- const content = readFileSync5(filePath, "utf-8");
4487
+ const content = readFileSync7(filePath, "utf-8");
4404
4488
  const lines = content.split("\n").filter(Boolean);
4405
4489
  const messages = [];
4406
4490
  for (let i = 0; i < lines.length; i++) {
@@ -4451,7 +4535,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
4451
4535
  const sessions = [];
4452
4536
  for (const file of batch) {
4453
4537
  const sessionId = file.replace(".jsonl", "");
4454
- const filePath = join6(projectsDir, file);
4538
+ const filePath = join9(projectsDir, file);
4455
4539
  try {
4456
4540
  const allMessages = parseTranscriptFile(filePath);
4457
4541
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -4480,38 +4564,37 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
4480
4564
  }
4481
4565
  for (const file of batch) {
4482
4566
  const sessionId = file.replace(".jsonl", "");
4483
- const filePath = join6(projectsDir, file);
4567
+ const filePath = join9(projectsDir, file);
4484
4568
  try {
4485
- const content = readFileSync5(filePath, "utf-8");
4569
+ const content = readFileSync7(filePath, "utf-8");
4486
4570
  const lineCount = content.split("\n").filter(Boolean).length;
4487
- writeFileSync5(join6(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
4571
+ writeFileSync7(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
4488
4572
  } catch {
4489
4573
  }
4490
4574
  }
4491
4575
  }
4492
4576
  return { sessions: totalSessions, messages: totalMessages };
4493
4577
  }
4494
- var SYNKRO_DIR2, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH, OFFSETS_DIR;
4495
- var init_install = __esm({
4578
+ var SYNKRO_DIR2, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, OFFSETS_DIR;
4579
+ var init_install2 = __esm({
4496
4580
  "cli/commands/install.ts"() {
4497
4581
  "use strict";
4498
4582
  init_agentDetect();
4499
4583
  init_ccHookConfig();
4500
4584
  init_mcpConfig();
4501
4585
  init_hookScripts();
4502
- init_graderDaemon();
4503
4586
  init_stub();
4504
4587
  init_repoConnect();
4505
4588
  init_projects();
4506
4589
  init_setupGithub();
4507
- SYNKRO_DIR2 = join6(homedir5(), ".synkro");
4508
- HOOKS_DIR = join6(SYNKRO_DIR2, "hooks");
4509
- BIN_DIR = join6(SYNKRO_DIR2, "bin");
4510
- CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
4511
- GRADER_DAEMON_PATH = join6(BIN_DIR, "grader_daemon.py");
4512
- GRADER_PRIMER_EDIT_PATH = join6(SYNKRO_DIR2, "grader-primer-edit.txt");
4513
- GRADER_PRIMER_BASH_PATH = join6(SYNKRO_DIR2, "grader-primer-bash.txt");
4514
- OFFSETS_DIR = join6(SYNKRO_DIR2, ".transcript-offsets");
4590
+ init_promptFetcher();
4591
+ init_install();
4592
+ init_pueue();
4593
+ SYNKRO_DIR2 = join9(homedir8(), ".synkro");
4594
+ HOOKS_DIR = join9(SYNKRO_DIR2, "hooks");
4595
+ BIN_DIR = join9(SYNKRO_DIR2, "bin");
4596
+ CONFIG_PATH2 = join9(SYNKRO_DIR2, "config.env");
4597
+ OFFSETS_DIR = join9(SYNKRO_DIR2, ".transcript-offsets");
4515
4598
  }
4516
4599
  });
4517
4600
 
@@ -4587,13 +4670,13 @@ var status_exports = {};
4587
4670
  __export(status_exports, {
4588
4671
  statusCommand: () => statusCommand
4589
4672
  });
4590
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
4591
- import { homedir as homedir6 } from "os";
4592
- import { join as join7 } from "path";
4673
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
4674
+ import { homedir as homedir9 } from "os";
4675
+ import { join as join10 } from "path";
4593
4676
  function readConfigEnv() {
4594
- if (!existsSync8(CONFIG_PATH3)) return {};
4677
+ if (!existsSync10(CONFIG_PATH3)) return {};
4595
4678
  const out = {};
4596
- const raw = readFileSync6(CONFIG_PATH3, "utf-8");
4679
+ const raw = readFileSync8(CONFIG_PATH3, "utf-8");
4597
4680
  for (const line of raw.split("\n")) {
4598
4681
  const trimmed = line.trim();
4599
4682
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -4666,19 +4749,19 @@ async function statusCommand() {
4666
4749
  }
4667
4750
  }
4668
4751
  console.log();
4669
- const bashScript = join7(SYNKRO_DIR3, "hooks", "cc-bash-judge.sh");
4670
- const bashFollowupScript = join7(SYNKRO_DIR3, "hooks", "cc-bash-followup.sh");
4671
- const editPrecheckScript = join7(SYNKRO_DIR3, "hooks", "cc-edit-precheck.sh");
4672
- const editCaptureScript = join7(SYNKRO_DIR3, "hooks", "cc-edit-capture.sh");
4673
- const stopSummaryScript = join7(SYNKRO_DIR3, "hooks", "cc-stop-summary.sh");
4674
- const sessionStartScript = join7(SYNKRO_DIR3, "hooks", "cc-session-start.sh");
4752
+ const bashScript = join10(SYNKRO_DIR3, "hooks", "cc-bash-judge.sh");
4753
+ const bashFollowupScript = join10(SYNKRO_DIR3, "hooks", "cc-bash-followup.sh");
4754
+ const editPrecheckScript = join10(SYNKRO_DIR3, "hooks", "cc-edit-precheck.sh");
4755
+ const editCaptureScript = join10(SYNKRO_DIR3, "hooks", "cc-edit-capture.sh");
4756
+ const stopSummaryScript = join10(SYNKRO_DIR3, "hooks", "cc-stop-summary.sh");
4757
+ const sessionStartScript = join10(SYNKRO_DIR3, "hooks", "cc-session-start.sh");
4675
4758
  console.log("Hook scripts:");
4676
- console.log(` ${existsSync8(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
4677
- console.log(` ${existsSync8(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
4678
- console.log(` ${existsSync8(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
4679
- console.log(` ${existsSync8(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
4680
- console.log(` ${existsSync8(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
4681
- console.log(` ${existsSync8(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
4759
+ console.log(` ${existsSync10(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
4760
+ console.log(` ${existsSync10(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
4761
+ console.log(` ${existsSync10(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
4762
+ console.log(` ${existsSync10(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
4763
+ console.log(` ${existsSync10(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
4764
+ console.log(` ${existsSync10(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
4682
4765
  console.log();
4683
4766
  const mcp = inspectMcpConfig();
4684
4767
  console.log("Guardrails MCP server (Claude Code):");
@@ -4698,8 +4781,8 @@ var init_status = __esm({
4698
4781
  init_agentDetect();
4699
4782
  init_ccHookConfig();
4700
4783
  init_mcpConfig();
4701
- SYNKRO_DIR3 = join7(homedir6(), ".synkro");
4702
- CONFIG_PATH3 = join7(SYNKRO_DIR3, "config.env");
4784
+ SYNKRO_DIR3 = join10(homedir9(), ".synkro");
4785
+ CONFIG_PATH3 = join10(SYNKRO_DIR3, "config.env");
4703
4786
  }
4704
4787
  });
4705
4788
 
@@ -4788,13 +4871,13 @@ var config_exports = {};
4788
4871
  __export(config_exports, {
4789
4872
  configCommand: () => configCommand
4790
4873
  });
4791
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
4792
- import { join as join8 } from "path";
4793
- import { homedir as homedir7 } from "os";
4874
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync8, existsSync as existsSync11 } from "fs";
4875
+ import { join as join11 } from "path";
4876
+ import { homedir as homedir10 } from "os";
4794
4877
  function readConfigEnv2() {
4795
- if (!existsSync9(CONFIG_PATH4)) return {};
4878
+ if (!existsSync11(CONFIG_PATH4)) return {};
4796
4879
  const out = {};
4797
- for (const line of readFileSync7(CONFIG_PATH4, "utf-8").split("\n")) {
4880
+ for (const line of readFileSync9(CONFIG_PATH4, "utf-8").split("\n")) {
4798
4881
  const t = line.trim();
4799
4882
  if (!t || t.startsWith("#")) continue;
4800
4883
  const eq = t.indexOf("=");
@@ -4803,11 +4886,11 @@ function readConfigEnv2() {
4803
4886
  return out;
4804
4887
  }
4805
4888
  function updateConfigValue(key, value) {
4806
- if (!existsSync9(CONFIG_PATH4)) {
4889
+ if (!existsSync11(CONFIG_PATH4)) {
4807
4890
  console.error("No config found. Run `synkro install` first.");
4808
4891
  process.exit(1);
4809
4892
  }
4810
- const lines = readFileSync7(CONFIG_PATH4, "utf-8").split("\n");
4893
+ const lines = readFileSync9(CONFIG_PATH4, "utf-8").split("\n");
4811
4894
  const pattern = new RegExp(`^${key}=`);
4812
4895
  let found = false;
4813
4896
  const updated = lines.map((line) => {
@@ -4818,7 +4901,7 @@ function updateConfigValue(key, value) {
4818
4901
  return line;
4819
4902
  });
4820
4903
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
4821
- writeFileSync6(CONFIG_PATH4, updated.join("\n"), "utf-8");
4904
+ writeFileSync8(CONFIG_PATH4, updated.join("\n"), "utf-8");
4822
4905
  }
4823
4906
  async function configCommand(args2) {
4824
4907
  if (args2.length === 0) {
@@ -4874,8 +4957,8 @@ var init_config = __esm({
4874
4957
  "cli/commands/config.ts"() {
4875
4958
  "use strict";
4876
4959
  init_stub();
4877
- SYNKRO_DIR4 = join8(homedir7(), ".synkro");
4878
- CONFIG_PATH4 = join8(SYNKRO_DIR4, "config.env");
4960
+ SYNKRO_DIR4 = join11(homedir10(), ".synkro");
4961
+ CONFIG_PATH4 = join11(SYNKRO_DIR4, "config.env");
4879
4962
  }
4880
4963
  });
4881
4964
 
@@ -4885,8 +4968,8 @@ __export(scanPr_exports, {
4885
4968
  scanPrCommand: () => scanPrCommand
4886
4969
  });
4887
4970
  import { execSync as execSync6, spawn } from "child_process";
4888
- import { readFileSync as readFileSync8, existsSync as existsSync10 } from "fs";
4889
- import { join as join9 } from "path";
4971
+ import { readFileSync as readFileSync10, existsSync as existsSync12 } from "fs";
4972
+ import { join as join12 } from "path";
4890
4973
  function parseMatchSpec(condition) {
4891
4974
  if (!condition.startsWith("match_spec:")) return null;
4892
4975
  try {
@@ -5365,10 +5448,10 @@ function shouldFail(findings, threshold) {
5365
5448
  return findings.some((f) => order.indexOf(f.severity) >= thresholdIdx);
5366
5449
  }
5367
5450
  function readRepoDeps() {
5368
- const pkgPath = join9(process.cwd(), "package.json");
5369
- if (!existsSync10(pkgPath)) return {};
5451
+ const pkgPath = join12(process.cwd(), "package.json");
5452
+ if (!existsSync12(pkgPath)) return {};
5370
5453
  try {
5371
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
5454
+ const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
5372
5455
  return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
5373
5456
  } catch {
5374
5457
  return {};
@@ -5621,7 +5704,7 @@ async function updateCommand() {
5621
5704
  var init_update = __esm({
5622
5705
  "cli/commands/update.ts"() {
5623
5706
  "use strict";
5624
- init_install();
5707
+ init_install2();
5625
5708
  }
5626
5709
  });
5627
5710
 
@@ -5630,12 +5713,24 @@ var disconnect_exports = {};
5630
5713
  __export(disconnect_exports, {
5631
5714
  disconnectCommand: () => disconnectCommand
5632
5715
  });
5633
- import { existsSync as existsSync11, rmSync } from "fs";
5634
- import { homedir as homedir8 } from "os";
5635
- import { join as join10 } from "path";
5716
+ import { existsSync as existsSync13, rmSync } from "fs";
5717
+ import { homedir as homedir11 } from "os";
5718
+ import { join as join13 } from "path";
5719
+ function tearDownLocalCC() {
5720
+ let hadTask = false;
5721
+ try {
5722
+ hadTask = !!findTask();
5723
+ stopTask();
5724
+ } catch {
5725
+ }
5726
+ console.log(`${hadTask ? "\u2713" : "\xB7"} local-cc runtime: ${hadTask ? "stopped pueue task + tmux session" : "no live task"}`);
5727
+ uninstallLocalCC();
5728
+ console.log("\u2713 local-cc config: cleaned ~/.claude.json entries");
5729
+ }
5636
5730
  function disconnectCommand(args2 = []) {
5637
5731
  const purge = args2.includes("--purge");
5638
5732
  console.log("Synkro disconnect starting...\n");
5733
+ tearDownLocalCC();
5639
5734
  const agents = detectAgents();
5640
5735
  let sawClaudeCode = false;
5641
5736
  for (const agent of agents) {
@@ -5650,13 +5745,13 @@ function disconnectCommand(args2 = []) {
5650
5745
  console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
5651
5746
  }
5652
5747
  if (purge) {
5653
- if (existsSync11(SYNKRO_DIR5)) {
5748
+ if (existsSync13(SYNKRO_DIR5)) {
5654
5749
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
5655
5750
  console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
5656
5751
  } else {
5657
5752
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
5658
5753
  }
5659
- } else if (existsSync11(SYNKRO_DIR5)) {
5754
+ } else if (existsSync13(SYNKRO_DIR5)) {
5660
5755
  console.log(`Config preserved at ${SYNKRO_DIR5}. Run with --purge to remove.`);
5661
5756
  }
5662
5757
  console.log("\nSynkro disconnected.");
@@ -5668,7 +5763,9 @@ var init_disconnect = __esm({
5668
5763
  init_agentDetect();
5669
5764
  init_ccHookConfig();
5670
5765
  init_mcpConfig();
5671
- SYNKRO_DIR5 = join10(homedir8(), ".synkro");
5766
+ init_pueue();
5767
+ init_install();
5768
+ SYNKRO_DIR5 = join13(homedir11(), ".synkro");
5672
5769
  }
5673
5770
  });
5674
5771
 
@@ -5705,20 +5802,746 @@ var init_reinstall = __esm({
5705
5802
  "cli/commands/reinstall.ts"() {
5706
5803
  "use strict";
5707
5804
  init_disconnect();
5805
+ init_install2();
5806
+ }
5807
+ });
5808
+
5809
+ // cli/local-cc/turnLog.ts
5810
+ import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync8, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync, watchFile, unwatchFile } from "fs";
5811
+ import { dirname as dirname5, join as join14 } from "path";
5812
+ import { homedir as homedir12 } from "os";
5813
+ function truncate(s, max = PREVIEW_MAX) {
5814
+ if (s.length <= max) return s;
5815
+ return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
5816
+ }
5817
+ function extractSeverity(result) {
5818
+ const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
5819
+ if (!m) return void 0;
5820
+ try {
5821
+ const obj = JSON.parse(m[1]);
5822
+ if (obj.severity) return String(obj.severity);
5823
+ if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
5824
+ if (obj.type) return String(obj.type);
5825
+ if (obj.verdict) return String(obj.verdict);
5826
+ } catch {
5827
+ }
5828
+ return void 0;
5829
+ }
5830
+ function appendTurn(args2) {
5831
+ try {
5832
+ mkdirSync8(dirname5(TURN_LOG_PATH), { recursive: true });
5833
+ const entry = {
5834
+ ts: new Date(args2.startedAt).toISOString(),
5835
+ role: args2.role,
5836
+ duration_ms: Date.now() - args2.startedAt,
5837
+ status: args2.status,
5838
+ request_preview: truncate(args2.request),
5839
+ response_preview: args2.result ? truncate(args2.result) : "",
5840
+ severity: args2.result ? extractSeverity(args2.result) : void 0,
5841
+ error: args2.error
5842
+ };
5843
+ appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
5844
+ } catch {
5845
+ }
5846
+ }
5847
+ function readRecentTurns(n = 20) {
5848
+ if (!existsSync14(TURN_LOG_PATH)) return [];
5849
+ try {
5850
+ const size = statSync(TURN_LOG_PATH).size;
5851
+ if (size === 0) return [];
5852
+ const text = readFileSync11(TURN_LOG_PATH, "utf-8");
5853
+ const lines = text.split("\n").filter(Boolean);
5854
+ const lastN = lines.slice(-n).reverse();
5855
+ return lastN.map((line) => {
5856
+ try {
5857
+ return JSON.parse(line);
5858
+ } catch {
5859
+ return null;
5860
+ }
5861
+ }).filter((x) => x !== null);
5862
+ } catch {
5863
+ return [];
5864
+ }
5865
+ }
5866
+ function followTurns(onEntry) {
5867
+ try {
5868
+ mkdirSync8(dirname5(TURN_LOG_PATH), { recursive: true });
5869
+ if (!existsSync14(TURN_LOG_PATH)) {
5870
+ appendFileSync(TURN_LOG_PATH, "", "utf-8");
5871
+ }
5872
+ } catch {
5873
+ }
5874
+ let lastSize = (() => {
5875
+ try {
5876
+ return statSync(TURN_LOG_PATH).size;
5877
+ } catch {
5878
+ return 0;
5879
+ }
5880
+ })();
5881
+ let pendingPartial = "";
5882
+ const drainNewBytes = (from, to) => {
5883
+ if (to <= from) return;
5884
+ let fd = null;
5885
+ try {
5886
+ fd = openSync2(TURN_LOG_PATH, "r");
5887
+ const len = to - from;
5888
+ const buf = Buffer.alloc(len);
5889
+ readSync(fd, buf, 0, len, from);
5890
+ const text = pendingPartial + buf.toString("utf-8");
5891
+ const lastNewline = text.lastIndexOf("\n");
5892
+ if (lastNewline === -1) {
5893
+ pendingPartial = text;
5894
+ return;
5895
+ }
5896
+ const complete = text.slice(0, lastNewline);
5897
+ pendingPartial = text.slice(lastNewline + 1);
5898
+ for (const line of complete.split("\n")) {
5899
+ if (!line) continue;
5900
+ try {
5901
+ onEntry(JSON.parse(line));
5902
+ } catch {
5903
+ }
5904
+ }
5905
+ } catch {
5906
+ } finally {
5907
+ if (fd !== null) {
5908
+ try {
5909
+ closeSync2(fd);
5910
+ } catch {
5911
+ }
5912
+ }
5913
+ }
5914
+ };
5915
+ watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
5916
+ if (curr.size < lastSize) {
5917
+ lastSize = 0;
5918
+ pendingPartial = "";
5919
+ }
5920
+ if (curr.size > lastSize) {
5921
+ drainNewBytes(lastSize, curr.size);
5922
+ lastSize = curr.size;
5923
+ }
5924
+ });
5925
+ return () => unwatchFile(TURN_LOG_PATH);
5926
+ }
5927
+ var TURN_LOG_PATH, PREVIEW_MAX;
5928
+ var init_turnLog = __esm({
5929
+ "cli/local-cc/turnLog.ts"() {
5930
+ "use strict";
5931
+ TURN_LOG_PATH = join14(homedir12(), ".synkro", "cc_sessions", "turns.log");
5932
+ PREVIEW_MAX = 400;
5933
+ }
5934
+ });
5935
+
5936
+ // cli/local-cc/settings.ts
5937
+ import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
5938
+ import { homedir as homedir13 } from "os";
5939
+ import { join as join15 } from "path";
5940
+ function isLocalCCEnabled() {
5941
+ if (!existsSync15(CONFIG_PATH5)) return false;
5942
+ try {
5943
+ const content = readFileSync12(CONFIG_PATH5, "utf-8");
5944
+ const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
5945
+ return match?.[1] === "yes";
5946
+ } catch {
5947
+ return false;
5948
+ }
5949
+ }
5950
+ var CONFIG_PATH5;
5951
+ var init_settings = __esm({
5952
+ "cli/local-cc/settings.ts"() {
5953
+ "use strict";
5954
+ CONFIG_PATH5 = join15(homedir13(), ".synkro", "config.env");
5955
+ }
5956
+ });
5957
+
5958
+ // cli/local-cc/prompts.ts
5959
+ import { existsSync as existsSync16, readFileSync as readFileSync13 } from "fs";
5960
+ import { homedir as homedir14 } from "os";
5961
+ import { join as join16 } from "path";
5962
+ function loadCachedPrompts() {
5963
+ if (_cached) return _cached;
5964
+ if (!existsSync16(CACHE_PATH2)) {
5965
+ throw new Error("Prompts cache not found. Run `synkro install` or `synkro update` first.");
5966
+ }
5967
+ try {
5968
+ _cached = JSON.parse(readFileSync13(CACHE_PATH2, "utf-8"));
5969
+ return _cached;
5970
+ } catch {
5971
+ throw new Error("Prompts cache is corrupted. Run `synkro update` to refresh.");
5972
+ }
5973
+ }
5974
+ function getPrimer(role) {
5975
+ const cache = loadCachedPrompts();
5976
+ const primer = role === "grade-edit" ? cache.grader_primer_edit : cache.grader_primer_bash;
5977
+ if (!primer) {
5978
+ throw new Error(`No cached primer for role "${role}". Run \`synkro update\` to refresh prompts.`);
5979
+ }
5980
+ return primer;
5981
+ }
5982
+ function buildChannelContent(role, payload) {
5983
+ return `${getPrimer(role)}
5984
+
5985
+ ---
5986
+ PAYLOAD (the input to evaluate):
5987
+
5988
+ ${payload}`;
5989
+ }
5990
+ var CACHE_PATH2, _cached;
5991
+ var init_prompts = __esm({
5992
+ "cli/local-cc/prompts.ts"() {
5993
+ "use strict";
5994
+ CACHE_PATH2 = join16(homedir14(), ".synkro", "prompts", "judge-prompts.json");
5995
+ _cached = null;
5996
+ }
5997
+ });
5998
+
5999
+ // cli/local-cc/client.ts
6000
+ import { request as httpRequest } from "http";
6001
+ import { connect as connect2 } from "net";
6002
+ async function submitToChannel(role, payload, opts = {}) {
6003
+ const content = buildChannelContent(role, payload);
6004
+ const body = JSON.stringify({ role, content });
6005
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
6006
+ const startedAt = Date.now();
6007
+ try {
6008
+ const result = await new Promise((resolve2, reject) => {
6009
+ const req = httpRequest({
6010
+ host: CHANNEL_HOST,
6011
+ port: CHANNEL_PORT,
6012
+ method: "POST",
6013
+ path: "/submit",
6014
+ headers: {
6015
+ "Content-Type": "application/json",
6016
+ "Content-Length": Buffer.byteLength(body)
6017
+ },
6018
+ timeout: timeoutMs
6019
+ }, (res) => {
6020
+ const chunks = [];
6021
+ res.on("data", (c) => chunks.push(c));
6022
+ res.on("end", () => {
6023
+ const text = Buffer.concat(chunks).toString("utf-8");
6024
+ if (res.statusCode !== 200) {
6025
+ reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
6026
+ return;
6027
+ }
6028
+ try {
6029
+ const parsed = JSON.parse(text);
6030
+ if (parsed.error) {
6031
+ reject(new LocalCCError(parsed.error));
6032
+ return;
6033
+ }
6034
+ resolve2(String(parsed.result ?? ""));
6035
+ } catch (err) {
6036
+ reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
6037
+ }
6038
+ });
6039
+ });
6040
+ req.on("timeout", () => {
6041
+ req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
6042
+ });
6043
+ req.on("error", (err) => {
6044
+ const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
6045
+ reject(new LocalCCError(msg, err));
6046
+ });
6047
+ req.write(body);
6048
+ req.end();
6049
+ });
6050
+ appendTurn({ startedAt, role, request: payload, result, status: "ok" });
6051
+ return result;
6052
+ } catch (err) {
6053
+ const message = err.message ?? String(err);
6054
+ const status = /timed out/i.test(message) ? "timeout" : "error";
6055
+ appendTurn({ startedAt, role, request: payload, status, error: message });
6056
+ throw err;
6057
+ }
6058
+ }
6059
+ function isChannelAvailable(timeoutMs = 500) {
6060
+ return new Promise((resolve2) => {
6061
+ const sock = connect2(CHANNEL_PORT, CHANNEL_HOST);
6062
+ const done = (ok) => {
6063
+ try {
6064
+ sock.destroy();
6065
+ } catch {
6066
+ }
6067
+ resolve2(ok);
6068
+ };
6069
+ sock.once("connect", () => done(true));
6070
+ sock.once("error", () => done(false));
6071
+ sock.setTimeout(timeoutMs, () => done(false));
6072
+ });
6073
+ }
6074
+ var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
6075
+ var init_client = __esm({
6076
+ "cli/local-cc/client.ts"() {
6077
+ "use strict";
6078
+ init_prompts();
6079
+ init_turnLog();
6080
+ CHANNEL_HOST = "127.0.0.1";
6081
+ CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
6082
+ DEFAULT_TIMEOUT_MS = 9e4;
6083
+ LocalCCError = class extends Error {
6084
+ constructor(message, cause) {
6085
+ super(message);
6086
+ this.cause = cause;
6087
+ this.name = "LocalCCError";
6088
+ }
6089
+ cause;
6090
+ };
6091
+ }
6092
+ });
6093
+
6094
+ // cli/commands/localCc.ts
6095
+ var localCc_exports = {};
6096
+ __export(localCc_exports, {
6097
+ localCcCommand: () => localCcCommand
6098
+ });
6099
+ import { spawnSync as spawnSync3 } from "child_process";
6100
+ import { homedir as homedir15 } from "os";
6101
+ import { join as join17 } from "path";
6102
+ import { existsSync as existsSync17, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
6103
+ function printHelp() {
6104
+ console.log(`synkro local-cc \u2014 manage the local Claude Code inference session
6105
+
6106
+ OVERVIEW
6107
+ Routes Synkro's grading and intent-classification work through a long-running
6108
+ Claude Code session on this machine instead of remote Inngest+Gemini.
6109
+
6110
+ When enabled, three call sites switch over:
6111
+ \u2022 security grading on edit/bash hooks
6112
+ \u2022 intent classification (agent input \u2192 structured intent)
6113
+ \u2022 remediate intent classification
6114
+
6115
+ The session is hosted in a detached tmux session managed by a pueue task.
6116
+ The CLI talks to it via Claude Code's channels API: a Bun MCP plugin that
6117
+ pushes events into the session and receives Claude's responses through a
6118
+ \`reply\` MCP tool.
6119
+
6120
+ USAGE
6121
+ synkro local-cc <subcommand> [args]
6122
+
6123
+ SUBCOMMANDS
6124
+ enable Install plugin, start pueue task, flip toggle to local-cc
6125
+ disable Flip toggle back to inngest (pueue task left running)
6126
+ status Show provider toggle, pueue task state, channel reachability
6127
+ start Idempotently bring up the pueue task + wait for the channel
6128
+ stop Kill the tmux session and remove the pueue task
6129
+ restart stop, then start
6130
+ install Regenerate ~/.synkro/cc_sessions/ files (plugin, runner, settings)
6131
+ logs [N] [--raw] [--live]
6132
+ Show the last N (default 20) channel turns: when, role,
6133
+ duration, severity, request preview.
6134
+ --raw / -r include full request/response payloads
6135
+ --live / -f tail the log; print new turns as they arrive
6136
+ (Ctrl-C to exit)
6137
+ --tmux escape hatch \u2014 print the raw pueue/tmux
6138
+ pane log instead
6139
+ attach [--readonly] Attach to the tmux session hosting claude (Ctrl-B D to detach;
6140
+ --readonly / -r to attach view-only)
6141
+ test Send a smoke-test classification through the channel
6142
+ help Show this message
6143
+
6144
+ CONFIGURATION
6145
+ Provider toggle:
6146
+ Stored server-side in your inference settings (gradingProvider).
6147
+ Toggle via: synkro local-cc enable / disable
6148
+ Or via dashboard inference settings (set grading provider to claude-code).
6149
+
6150
+ Claude Code session settings (scoped to ~/.synkro/cc_sessions only):
6151
+ ${PLUGIN_SETTINGS_PATH}
6152
+ Currently sets {"fastMode": true}. Edit to add other CC settings for this
6153
+ session without affecting your other CC projects.
6154
+
6155
+ MCP server registration (for the channel plugin):
6156
+ ${CLAUDE_JSON_PATH}
6157
+ A 'synkro-local' entry under mcpServers, pointing at:
6158
+ bun ${PLUGIN_PATH}
6159
+ Workspace trust is also pre-accepted under projects[\`${SESSION_DIR}\`].
6160
+
6161
+ Channel runtime files:
6162
+ ${RUN_SCRIPT_PATH}
6163
+ bash wrapper that pueue invokes; owns the tmux session lifecycle.
6164
+ ${PLUGIN_PATH}
6165
+ Bun MCP channel plugin (auto-generated, do not edit).
6166
+ 127.0.0.1:${CHANNEL_PORT}
6167
+ Loopback TCP endpoint the CLI POSTs to in order to submit a request.
6168
+
6169
+ ENVIRONMENT VARIABLES
6170
+ SYNKRO_CHANNEL_PORT Override the TCP port used by both the plugin and
6171
+ the CLI client (loopback only). Default: 8929
6172
+ SYNKRO_CHANNEL_TIMEOUT_MS Per-request timeout inside the channel plugin
6173
+ (default: 120000)
6174
+
6175
+ REQUIRED TOOLS
6176
+ claude Claude Code CLI, authenticated to your subscription
6177
+ pueue pueue + pueued (https://github.com/Nukesor/pueue) running
6178
+ tmux For detached pty around claude
6179
+ bun Runtime for the MCP channel plugin
6180
+
6181
+ TROUBLESHOOTING
6182
+ \u2022 Channel unreachable after \`enable\`?
6183
+ synkro local-cc logs # check pueue task output
6184
+ tmux attach -t ${TMUX_SESSION_NAME} # see what claude is showing
6185
+ \u2022 Stale state after a crash:
6186
+ synkro local-cc stop && synkro local-cc start
6187
+ \u2022 Want to inspect or interact with the live session:
6188
+ synkro local-cc attach
6189
+ `);
6190
+ }
6191
+ function readGatewayUrl() {
6192
+ if (existsSync17(CONFIG_PATH6)) {
6193
+ const m = readFileSync14(CONFIG_PATH6, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
6194
+ if (m) return m[1];
6195
+ }
6196
+ return "https://api.synkro.sh";
6197
+ }
6198
+ function updateLocalInferenceFlag(enabled) {
6199
+ if (!existsSync17(CONFIG_PATH6)) return;
6200
+ let content = readFileSync14(CONFIG_PATH6, "utf-8");
6201
+ const flag = enabled ? "yes" : "no";
6202
+ if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
6203
+ content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
6204
+ } else {
6205
+ content = content.trimEnd() + `
6206
+ SYNKRO_LOCAL_INFERENCE='${flag}'
6207
+ `;
6208
+ }
6209
+ writeFileSync9(CONFIG_PATH6, content, "utf-8");
6210
+ }
6211
+ async function setServerGradingProvider(provider) {
6212
+ await ensureValidToken();
6213
+ const jwt2 = getAccessToken();
6214
+ if (!jwt2) throw new Error("Not authenticated. Run `synkro install` first.");
6215
+ const gatewayUrl = readGatewayUrl();
6216
+ const body = provider ? { roles: { grading: { provider, model: "default" } } } : { roles: { grading: { provider: null, model: null } } };
6217
+ const resp = await fetch(`${gatewayUrl}/api/settings/inference?scope=user`, {
6218
+ method: "PUT",
6219
+ headers: { "Authorization": `Bearer ${jwt2}`, "Content-Type": "application/json" },
6220
+ body: JSON.stringify(body)
6221
+ });
6222
+ if (!resp.ok) {
6223
+ const text = await resp.text().catch(() => "");
6224
+ throw new Error(`Failed to update inference settings: ${resp.status} ${text.slice(0, 200)}`);
6225
+ }
6226
+ }
6227
+ async function cmdStatus() {
6228
+ console.log(`Local inference: ${isLocalCCEnabled() ? "enabled" : "disabled"}`);
6229
+ try {
6230
+ assertPueueInstalled();
6231
+ } catch (err) {
6232
+ console.log(`Pueue: NOT AVAILABLE (${err.message})`);
6233
+ return;
6234
+ }
6235
+ const t = findTask();
6236
+ if (!t) {
6237
+ console.log("Pueue task: not present");
6238
+ } else {
6239
+ console.log(`Pueue task: id=${t.id} status=${t.status} cwd=${t.cwd}`);
6240
+ console.log(` command: ${t.command}`);
6241
+ }
6242
+ const channelUp = await isChannelAvailable();
6243
+ console.log(`Channel ${CHANNEL_HOST}:${CHANNEL_PORT}: ${channelUp ? "reachable" : "unreachable"}`);
6244
+ const tmuxCheck = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
6245
+ console.log(`tmux session '${TMUX_SESSION_NAME}': ${tmuxCheck.status === 0 ? "live" : "absent"}`);
6246
+ }
6247
+ async function cmdEnable() {
6248
+ assertClaudeInstalled();
6249
+ assertPueueInstalled();
6250
+ assertTmuxInstalled();
6251
+ console.log("Installing local-CC channel plugin...");
6252
+ const r = installLocalCC();
6253
+ console.log(` plugin: ${r.pluginPath}`);
6254
+ console.log(` cwd: ${r.sessionDir}`);
6255
+ console.log("Starting pueue task...");
6256
+ const t = ensureRunning();
6257
+ console.log(` task: id=${t.id} status=${t.status}`);
6258
+ console.log("Waiting for channel (auto-confirming any CC prompts)...");
6259
+ const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6260
+ if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
6261
+ else console.warn(` \u26A0 channel did not come up within 60s \u2014 check \`synkro local-cc logs\``);
6262
+ console.log("Updating inference settings...");
6263
+ await setServerGradingProvider("claude-code");
6264
+ updateLocalInferenceFlag(true);
6265
+ console.log("Grading provider set to claude-code (local inference enabled).");
6266
+ }
6267
+ async function cmdDisable() {
6268
+ console.log("Updating inference settings...");
6269
+ await setServerGradingProvider(null);
6270
+ updateLocalInferenceFlag(false);
6271
+ console.log("Grading provider cleared (remote inference restored). Pueue task left running \u2014 use `synkro local-cc stop` to terminate.");
6272
+ }
6273
+ async function cmdStart() {
6274
+ assertClaudeInstalled();
6275
+ assertPueueInstalled();
6276
+ assertTmuxInstalled();
6277
+ const t = ensureRunning();
6278
+ console.log(`Pueue task: id=${t.id} status=${t.status}`);
6279
+ const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6280
+ console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
6281
+ }
6282
+ function cmdStop() {
6283
+ stopTask();
6284
+ console.log("Pueue task stopped.");
6285
+ }
6286
+ async function cmdRestart() {
6287
+ stopTask();
6288
+ const t = startTask();
6289
+ console.log(`Pueue task restarted: id=${t.id} status=${t.status}`);
6290
+ const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6291
+ console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
6292
+ }
6293
+ function relativeTime(iso) {
6294
+ const ts = new Date(iso).getTime();
6295
+ if (!Number.isFinite(ts)) return iso;
6296
+ const sec = Math.max(0, Math.round((Date.now() - ts) / 1e3));
6297
+ if (sec < 60) return `${sec}s ago`;
6298
+ if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
6299
+ if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
6300
+ return `${Math.round(sec / 86400)}d ago`;
6301
+ }
6302
+ function colorize(s, code) {
6303
+ if (!process.stdout.isTTY) return s;
6304
+ return `\x1B[${code}m${s}\x1B[0m`;
6305
+ }
6306
+ function statusGlyph(t) {
6307
+ if (t.status === "ok") return colorize("\u2713", 32);
6308
+ if (t.status === "timeout") return colorize("\u23F1", 33);
6309
+ return colorize("\u2717", 31);
6310
+ }
6311
+ function severityCell(t) {
6312
+ if (t.severity) {
6313
+ const sev = t.severity;
6314
+ if (sev === "block" || sev === "violations" || sev === "unclear") return colorize(sev, 33);
6315
+ return colorize(sev, 36);
6316
+ }
6317
+ if (t.error) return colorize(t.error.slice(0, 40), 31);
6318
+ return "\u2014";
6319
+ }
6320
+ function firstLine(s) {
6321
+ return s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
6322
+ }
6323
+ function formatTurn(t, raw) {
6324
+ const when = relativeTime(t.ts).padEnd(8);
6325
+ const dur = (t.duration_ms < 1e3 ? `${t.duration_ms}ms` : `${(t.duration_ms / 1e3).toFixed(1)}s`).padStart(7);
6326
+ const role = t.role.padEnd(24);
6327
+ const sev = severityCell(t).padEnd(20);
6328
+ const preview = (() => {
6329
+ const req = firstLine(t.request_preview).slice(0, 60);
6330
+ return colorize(req, 90);
6331
+ })();
6332
+ const head = `${statusGlyph(t)} ${when} ${dur} ${role} ${sev} ${preview}`;
6333
+ if (!raw) return head;
6334
+ const blocks = [head];
6335
+ blocks.push(colorize(" request:", 90));
6336
+ blocks.push(" " + t.request_preview.replace(/\n/g, "\n "));
6337
+ if (t.response_preview) {
6338
+ blocks.push(colorize(" response:", 90));
6339
+ blocks.push(" " + t.response_preview.replace(/\n/g, "\n "));
6340
+ }
6341
+ if (t.error) {
6342
+ blocks.push(colorize(" error:", 31) + " " + t.error);
6343
+ }
6344
+ return blocks.join("\n");
6345
+ }
6346
+ function cmdLogs(rest) {
6347
+ let n = 20;
6348
+ let raw = false;
6349
+ let live = false;
6350
+ for (const arg of rest) {
6351
+ if (arg === "--raw" || arg === "-r") raw = true;
6352
+ else if (arg === "--live" || arg === "-f") live = true;
6353
+ else if (arg === "--tmux") {
6354
+ console.log(tailLogs(80));
6355
+ return;
6356
+ } else {
6357
+ const parsed = parseInt(arg, 10);
6358
+ if (parsed > 0) n = parsed;
6359
+ }
6360
+ }
6361
+ const header = " " + colorize("status when dur role severity request", 90);
6362
+ const turns = readRecentTurns(n);
6363
+ if (turns.length === 0) {
6364
+ if (!live) {
6365
+ console.log(`No turns logged yet at ${TURN_LOG_PATH}.`);
6366
+ console.log("Run a few requests through the channel (synkro local-cc test) and try again.");
6367
+ return;
6368
+ }
6369
+ console.log(`No turns logged yet at ${TURN_LOG_PATH} \u2014 waiting for new entries\u2026 (Ctrl-C to exit)`);
6370
+ } else {
6371
+ console.log(`Last ${turns.length} channel turn(s) (newest first):`);
6372
+ console.log(header);
6373
+ for (const t of turns) console.log(" " + formatTurn(t, raw));
6374
+ }
6375
+ if (!live) {
6376
+ if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
6377
+ return;
6378
+ }
6379
+ return new Promise((resolve2) => {
6380
+ console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
6381
+ const stop = followTurns((t) => {
6382
+ console.log(" " + formatTurn(t, raw));
6383
+ });
6384
+ const onSigint = () => {
6385
+ stop();
6386
+ process.removeListener("SIGINT", onSigint);
6387
+ resolve2();
6388
+ };
6389
+ process.on("SIGINT", onSigint);
6390
+ });
6391
+ }
6392
+ function cmdAttach(rest) {
6393
+ assertTmuxInstalled();
6394
+ const readonly = rest.some((a) => a === "--readonly" || a === "-r");
6395
+ const has = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
6396
+ if (has.status !== 0) {
6397
+ console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
6398
+ process.exit(1);
6399
+ }
6400
+ if (!process.stdout.isTTY) {
6401
+ console.error("attach requires a TTY. Run this command directly in your terminal, not piped.");
6402
+ process.exit(1);
6403
+ }
6404
+ console.log(`Attaching to tmux session '${TMUX_SESSION_NAME}'${readonly ? " (read-only)" : ""}.`);
6405
+ console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
6406
+ console.log();
6407
+ const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
6408
+ const r = spawnSync3("tmux", args2, { stdio: "inherit" });
6409
+ process.exit(r.status ?? 0);
6410
+ }
6411
+ async function cmdTest() {
6412
+ console.log("Sending smoke-test grading request through the channel...");
6413
+ const result = await submitToChannel(
6414
+ "grade-bash",
6415
+ 'Command: echo "hello world"\nIntent: testing channel connectivity'
6416
+ );
6417
+ console.log("Raw reply:");
6418
+ console.log(result);
6419
+ }
6420
+ function cmdInstall() {
6421
+ const r = installLocalCC();
6422
+ console.log(`Reinstalled plugin at ${r.pluginPath}`);
6423
+ }
6424
+ async function localCcCommand(args2) {
6425
+ const sub = args2[0] ?? "";
6426
+ try {
6427
+ switch (sub) {
6428
+ case "enable":
6429
+ await cmdEnable();
6430
+ break;
6431
+ case "disable":
6432
+ cmdDisable();
6433
+ break;
6434
+ case "status":
6435
+ await cmdStatus();
6436
+ break;
6437
+ case "start":
6438
+ await cmdStart();
6439
+ break;
6440
+ case "stop":
6441
+ cmdStop();
6442
+ break;
6443
+ case "restart":
6444
+ await cmdRestart();
6445
+ break;
6446
+ case "install":
6447
+ cmdInstall();
6448
+ break;
6449
+ case "logs":
6450
+ await cmdLogs(args2.slice(1));
6451
+ break;
6452
+ case "attach":
6453
+ cmdAttach(args2.slice(1));
6454
+ break;
6455
+ case "test":
6456
+ await cmdTest();
6457
+ break;
6458
+ case "":
6459
+ case "help":
6460
+ case "--help":
6461
+ case "-h":
6462
+ printHelp();
6463
+ break;
6464
+ default:
6465
+ console.error(`Unknown subcommand: ${sub}`);
6466
+ printHelp();
6467
+ process.exit(1);
6468
+ }
6469
+ } catch (err) {
6470
+ console.error(err.message);
6471
+ process.exit(1);
6472
+ }
6473
+ }
6474
+ var CONFIG_PATH6;
6475
+ var init_localCc = __esm({
6476
+ "cli/commands/localCc.ts"() {
6477
+ "use strict";
5708
6478
  init_install();
6479
+ init_turnLog();
6480
+ init_pueue();
6481
+ init_settings();
6482
+ init_client();
6483
+ init_stub();
6484
+ CONFIG_PATH6 = join17(homedir15(), ".synkro", "config.env");
6485
+ }
6486
+ });
6487
+
6488
+ // cli/commands/grade.ts
6489
+ var grade_exports = {};
6490
+ __export(grade_exports, {
6491
+ gradeCommand: () => gradeCommand
6492
+ });
6493
+ async function readStdin() {
6494
+ return new Promise((resolve2, reject) => {
6495
+ const chunks = [];
6496
+ process.stdin.on("data", (c) => chunks.push(c));
6497
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
6498
+ process.stdin.on("error", reject);
6499
+ });
6500
+ }
6501
+ async function gradeCommand(args2) {
6502
+ const mode = args2[0] ?? "";
6503
+ let role;
6504
+ if (mode === "edit") role = "grade-edit";
6505
+ else if (mode === "bash") role = "grade-bash";
6506
+ else {
6507
+ console.error("Usage: synkro grade <edit|bash>");
6508
+ process.exit(2);
6509
+ }
6510
+ const payload = await readStdin();
6511
+ if (!payload.trim()) {
6512
+ console.error("synkro grade: empty stdin");
6513
+ process.exit(2);
6514
+ }
6515
+ try {
6516
+ const result = await submitToChannel(role, payload, { timeoutMs: 6e4 });
6517
+ process.stdout.write(result);
6518
+ if (!result.endsWith("\n")) process.stdout.write("\n");
6519
+ } catch (err) {
6520
+ if (err instanceof LocalCCError) {
6521
+ console.error(`synkro grade: ${err.message}`);
6522
+ } else {
6523
+ console.error(`synkro grade: ${err.message}`);
6524
+ }
6525
+ process.exit(3);
6526
+ }
6527
+ }
6528
+ var init_grade = __esm({
6529
+ "cli/commands/grade.ts"() {
6530
+ "use strict";
6531
+ init_client();
5709
6532
  }
5710
6533
  });
5711
6534
 
5712
6535
  // cli/bootstrap.js
5713
- import { readFileSync as readFileSync9, existsSync as existsSync12 } from "fs";
6536
+ import { readFileSync as readFileSync15, existsSync as existsSync18 } from "fs";
5714
6537
  import { resolve } from "path";
5715
6538
  var envCandidates = [
5716
6539
  resolve(process.cwd(), ".env"),
5717
6540
  resolve(process.env.HOME ?? "", ".synkro", "config.env")
5718
6541
  ];
5719
6542
  for (const envPath of envCandidates) {
5720
- if (!existsSync12(envPath)) continue;
5721
- const envContent = readFileSync9(envPath, "utf-8");
6543
+ if (!existsSync18(envPath)) continue;
6544
+ const envContent = readFileSync15(envPath, "utf-8");
5722
6545
  for (const line of envContent.split("\n")) {
5723
6546
  const trimmed = line.trim();
5724
6547
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -5732,7 +6555,7 @@ for (const envPath of envCandidates) {
5732
6555
  var args = process.argv.slice(2);
5733
6556
  var cmd = args[0] || "";
5734
6557
  var subArgs = args.slice(1);
5735
- function printHelp() {
6558
+ function printHelp2() {
5736
6559
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
5737
6560
 
5738
6561
  Usage:
@@ -5752,6 +6575,8 @@ Commands:
5752
6575
  disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
5753
6576
  uninstall Fully remove Synkro from this machine
5754
6577
  reinstall Clean uninstall + fresh install
6578
+ local-cc <sub> Manage the local Claude Code inference session (enable/disable/status/start/stop/logs/test)
6579
+ grade <edit|bash> Internal: read prompt from stdin, return verdict (called by hook scripts)
5755
6580
  help Show this message
5756
6581
 
5757
6582
  Quick start:
@@ -5764,7 +6589,7 @@ Quick start:
5764
6589
  async function main() {
5765
6590
  switch (cmd) {
5766
6591
  case "install": {
5767
- const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install(), install_exports));
6592
+ const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install2(), install_exports));
5768
6593
  await installCommand2(parseArgs2(subArgs));
5769
6594
  break;
5770
6595
  }
@@ -5835,16 +6660,26 @@ async function main() {
5835
6660
  await reinstallCommand2();
5836
6661
  break;
5837
6662
  }
6663
+ case "local-cc": {
6664
+ const { localCcCommand: localCcCommand2 } = await Promise.resolve().then(() => (init_localCc(), localCc_exports));
6665
+ await localCcCommand2(subArgs);
6666
+ break;
6667
+ }
6668
+ case "grade": {
6669
+ const { gradeCommand: gradeCommand2 } = await Promise.resolve().then(() => (init_grade(), grade_exports));
6670
+ await gradeCommand2(subArgs);
6671
+ break;
6672
+ }
5838
6673
  case "help":
5839
6674
  case "--help":
5840
6675
  case "-h":
5841
6676
  case "": {
5842
- printHelp();
6677
+ printHelp2();
5843
6678
  break;
5844
6679
  }
5845
6680
  default: {
5846
6681
  console.error(`Unknown command: ${cmd}`);
5847
- printHelp();
6682
+ printHelp2();
5848
6683
  process.exit(1);
5849
6684
  }
5850
6685
  }