@synkro-sh/cli 1.3.59 → 1.4.0

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