@synkro-sh/cli 1.6.53 → 1.6.54

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
@@ -1670,6 +1670,13 @@ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
1670
1670
  'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
1671
1671
  'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
1672
1672
  'git',
1673
+ // Read-only system / inspection utilities (no mutation, no exec). Deliberately
1674
+ // EXCLUDES curl/wget (exfil + -o write), sed/awk (w/e write+exec), env (command
1675
+ // runner), tree (-o write) \u2014 those stay graded. Keep in sync with scanning/safeRead.ts.
1676
+ 'lsof','ps','df','du','uname','hostname','uptime',
1677
+ 'basename','dirname','realpath','readlink',
1678
+ 'shasum','md5','md5sum','sha256sum','cksum',
1679
+ 'docker',
1673
1680
  ]);
1674
1681
  const tokens = seg.trim().split(' ').filter(t => t.length > 0);
1675
1682
  const verb = tokens[0] || '';
@@ -1699,6 +1706,15 @@ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
1699
1706
  } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
1700
1707
  const sub = tokens[1] || '';
1701
1708
  if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
1709
+ } else if (verb === 'docker') {
1710
+ // Read-only docker subcommands only \u2014 same gating pattern as git. Excludes
1711
+ // run/exec/rm/rmi/kill/stop/start/build/buildx/push/pull/cp/commit/save/load/
1712
+ // compose and every mutating verb; those fall through to the judge.
1713
+ const SAFE_DOCKER = new Set([
1714
+ 'ps','logs','images','inspect','version','info','top','port','history','diff',
1715
+ ]);
1716
+ const sub = tokens[1] || '';
1717
+ if (!SAFE_DOCKER.has(sub)) return false;
1702
1718
  }
1703
1719
  if (!repoRoot) return false;
1704
1720
  for (let i = 1; i < tokens.length; i++) {
@@ -7341,6 +7357,510 @@ var init_promptFetcher = __esm({
7341
7357
  }
7342
7358
  });
7343
7359
 
7360
+ // cli/installer/claudeDesktopTap.ts
7361
+ var claudeDesktopTap_exports = {};
7362
+ __export(claudeDesktopTap_exports, {
7363
+ claudeDesktopInstalled: () => claudeDesktopInstalled,
7364
+ runClaudeDesktopTap: () => runClaudeDesktopTap
7365
+ });
7366
+ import { spawn } from "child_process";
7367
+ import { writeFileSync as writeFileSync6, mkdtempSync, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
7368
+ import { join as join5 } from "path";
7369
+ import { tmpdir, homedir as homedir5 } from "os";
7370
+ function claudeDesktopInstalled() {
7371
+ return process.platform === "darwin" && existsSync7("/Applications/Claude.app/Contents/MacOS/Claude");
7372
+ }
7373
+ function buildRunner(sessionDir) {
7374
+ return `#!/bin/bash
7375
+ set -e
7376
+ SESSION_DIR=${JSON.stringify(sessionDir)}
7377
+ PROXY_PORT=8080
7378
+ MITM_CA_DIR="$SESSION_DIR/ca"
7379
+ TAP_SCRIPT="$SESSION_DIR/tap.py"
7380
+ CA_FINGERPRINT=""
7381
+ MITM_PID=""
7382
+ SUDO_KEEPALIVE_PID=""
7383
+ MCP_PROXY="$SESSION_DIR/mcp_proxy.py"
7384
+ MCP_PATCH="$SESSION_DIR/mcp_patch.py"
7385
+ CLAUDE_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
7386
+ CLAUDE_CONFIG_BACKUP="$SESSION_DIR/claude_desktop_config.backup.json"
7387
+ MCP_PATCHED=false
7388
+
7389
+ cleanup() {
7390
+ echo ""
7391
+ echo "Stopping Claude Desktop observability..."
7392
+ [ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null
7393
+ [ -n "$SUDO_KEEPALIVE_PID" ] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null
7394
+ # Restore the original MCP config (unwrap the proxy) on exit.
7395
+ if [ "$MCP_PATCHED" = "true" ] && [ -f "$CLAUDE_CONFIG_BACKUP" ]; then
7396
+ cp "$CLAUDE_CONFIG_BACKUP" "$CLAUDE_CONFIG" && echo " MCP config restored"
7397
+ fi
7398
+ if [ -n "$CA_FINGERPRINT" ]; then
7399
+ # Non-interactive (-n) so teardown can NEVER hang on a sudo password prompt
7400
+ # (which would swallow the user's Ctrl+C). The keepalive below keeps creds
7401
+ # warm so this normally succeeds without a prompt.
7402
+ if sudo -n security delete-certificate -Z "$CA_FINGERPRINT" -t /Library/Keychains/System.keychain 2>/dev/null; then
7403
+ echo " Session CA removed from keychain"
7404
+ else
7405
+ echo " Could not auto-remove the session CA (sudo timed out)."
7406
+ echo " Remove it manually: sudo security delete-certificate -c mitmproxy /Library/Keychains/System.keychain"
7407
+ fi
7408
+ fi
7409
+ echo "Done."
7410
+ exit 0
7411
+ }
7412
+ trap cleanup INT TERM EXIT
7413
+
7414
+ if ! command -v mitmdump &>/dev/null; then
7415
+ echo "Installing mitmproxy..."
7416
+ if command -v brew &>/dev/null; then brew install mitmproxy; else echo "mitmproxy required: brew install mitmproxy"; exit 1; fi
7417
+ fi
7418
+
7419
+ mkdir -p "$MITM_CA_DIR"
7420
+ echo "Generating session CA certificate..."
7421
+ mitmdump --set confdir="$MITM_CA_DIR" --listen-port 0 </dev/null &>/dev/null &
7422
+ TMPPID=$!; sleep 2; kill $TMPPID 2>/dev/null || true
7423
+
7424
+ echo "Trusting session CA (sudo \u2014 removed automatically on exit)..."
7425
+ sudo security add-trusted-cert -d -r trustRoot -k "/Library/Keychains/System.keychain" "$MITM_CA_DIR/mitmproxy-ca-cert.pem"
7426
+ CA_FINGERPRINT=$(openssl x509 -in "$MITM_CA_DIR/mitmproxy-ca-cert.pem" -noout -fingerprint -sha1 2>/dev/null | sed "s/.*=//;s/://g")
7427
+
7428
+ # Keep the sudo timestamp warm for the life of the session so teardown's CA
7429
+ # removal never blocks on a password prompt (a prompt would eat Ctrl+C). Exits
7430
+ # itself if creds are ever revoked. Killed in cleanup().
7431
+ ( while true; do sudo -n true 2>/dev/null || exit 0; sleep 30; done ) &
7432
+ SUDO_KEEPALIVE_PID=$!
7433
+
7434
+ lsof -ti:$PROXY_PORT 2>/dev/null | xargs kill 2>/dev/null || true
7435
+ osascript -e 'tell app "Claude" to quit' 2>/dev/null || true
7436
+ sleep 1
7437
+
7438
+ # Patch Claude Desktop's MCP config to wrap each server with the Synkro stdio
7439
+ # proxy (restored on exit). Capture URL + token come from the inherited env.
7440
+ if [ -f "$CLAUDE_CONFIG" ]; then
7441
+ cp "$CLAUDE_CONFIG" "$CLAUDE_CONFIG_BACKUP"
7442
+ if python3 "$MCP_PATCH" "$CLAUDE_CONFIG" "$MCP_PROXY" "$SYNKRO_CAPTURE_URL" "$SYNKRO_TAP_TOKEN" 2>/dev/null; then
7443
+ MCP_PATCHED=true
7444
+ echo "MCP servers wrapped with Synkro proxy"
7445
+ else
7446
+ echo "No MCP servers configured \u2014 skipping MCP proxy"
7447
+ cp "$CLAUDE_CONFIG_BACKUP" "$CLAUDE_CONFIG" 2>/dev/null || true
7448
+ fi
7449
+ fi
7450
+
7451
+ echo "Starting proxy on :$PROXY_PORT..."
7452
+ # stdin from /dev/null so mitmdump can't grab the TTY in raw mode \u2014 otherwise it
7453
+ # captures keypresses (incl. Ctrl+C) instead of letting them become a signal.
7454
+ mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null &>/tmp/synkro-mitm.log &
7455
+ MITM_PID=$!
7456
+ sleep 2
7457
+
7458
+ CLAUDE_BIN=""
7459
+ for p in "/Applications/Claude.app/Contents/MacOS/Claude" "/Volumes/Claude/Claude.app/Contents/MacOS/Claude"; do
7460
+ [ -f "$p" ] && CLAUDE_BIN="$p" && break
7461
+ done
7462
+ if [ -z "$CLAUDE_BIN" ]; then
7463
+ CLAUDE_BIN="$(mdfind 'kMDItemFSName == "Claude.app"' 2>/dev/null | head -1)/Contents/MacOS/Claude"
7464
+ fi
7465
+ if [ ! -f "$CLAUDE_BIN" ]; then echo "Claude Desktop not found."; exit 1; fi
7466
+
7467
+ echo "Launching Claude Desktop..."
7468
+ "$CLAUDE_BIN" --proxy-server="http://localhost:$PROXY_PORT" &>/dev/null &
7469
+
7470
+ echo ""
7471
+ echo " \u2713 Claude Desktop is being monitored by Synkro (local)."
7472
+ echo " Your conversations will appear in the dashboard Events page."
7473
+ echo " Press Ctrl+C to stop (this removes the session CA cleanly)."
7474
+ echo ""
7475
+ wait $MITM_PID
7476
+ `;
7477
+ }
7478
+ async function runClaudeDesktopTap() {
7479
+ if (process.platform !== "darwin") {
7480
+ console.log(" Claude Desktop observability is macOS-only \u2014 skipped.");
7481
+ return;
7482
+ }
7483
+ let token = "";
7484
+ try {
7485
+ token = readFileSync5(JWT_PATH, "utf-8").trim();
7486
+ } catch {
7487
+ }
7488
+ if (!token) {
7489
+ console.log(" Could not read local auth token (~/.synkro/.mcp-jwt). Run `synkro install` first.");
7490
+ return;
7491
+ }
7492
+ const sessionDir = mkdtempSync(join5(tmpdir(), "synkro-cd-"));
7493
+ writeFileSync6(join5(sessionDir, "tap.py"), ADDON_PY, "utf-8");
7494
+ writeFileSync6(join5(sessionDir, "mcp_proxy.py"), MCP_PROXY_PY, { mode: 493 });
7495
+ writeFileSync6(join5(sessionDir, "mcp_patch.py"), MCP_PATCH_PY, "utf-8");
7496
+ const runnerPath = join5(sessionDir, "run.sh");
7497
+ writeFileSync6(runnerPath, buildRunner(sessionDir), { mode: 493 });
7498
+ await new Promise((resolve4) => {
7499
+ const child = spawn("bash", [runnerPath], {
7500
+ stdio: "inherit",
7501
+ env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_TAP_TOKEN: token }
7502
+ });
7503
+ const forward = (sig) => {
7504
+ try {
7505
+ child.kill(sig);
7506
+ } catch {
7507
+ }
7508
+ };
7509
+ process.on("SIGINT", forward);
7510
+ process.on("SIGTERM", forward);
7511
+ child.on("exit", () => {
7512
+ process.off("SIGINT", forward);
7513
+ process.off("SIGTERM", forward);
7514
+ resolve4();
7515
+ });
7516
+ });
7517
+ }
7518
+ var CAPTURE_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7519
+ var init_claudeDesktopTap = __esm({
7520
+ "cli/installer/claudeDesktopTap.ts"() {
7521
+ "use strict";
7522
+ CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
7523
+ JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7524
+ ADDON_PY = `import os, json, threading, urllib.request, re
7525
+ from mitmproxy import http
7526
+
7527
+ CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7528
+ TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7529
+
7530
+ # Governance signals parsed from the completion SSE (usage window + web-search
7531
+ # sources), keyed by conversation id. The completion POST always precedes the
7532
+ # tree GET for the same turn, so the tree capture below reads + clears this.
7533
+ # Guarded by a lock \u2014 tap callbacks can run concurrently across flows (CWE-362).
7534
+ _sse_meta = {}
7535
+ _sse_lock = threading.Lock()
7536
+ # Session-global MCP server registry (uuid -> {name,url,connected,tools}), parsed
7537
+ # from the mcp/v2/bootstrap SSE. All access guarded by _mcp_lock (CWE-362).
7538
+ _mcp_registry = {}
7539
+ _mcp_lock = threading.Lock()
7540
+
7541
+ class SynkroTap:
7542
+ def responseheaders(self, flow):
7543
+ # Buffer the small conversation-tree GET (parsed for the dashboard).
7544
+ # For the completion POST, TAP the SSE chunk-by-chunk and pass each
7545
+ # through unchanged \u2014 Claude Desktop keeps streaming normally while we
7546
+ # accumulate a bounded copy for the raw diagnostic dump. Everything else
7547
+ # streams untouched.
7548
+ r = flow.request
7549
+ if "claude.ai" in (r.pretty_host or "") and "chat_conversations" in r.path:
7550
+ if r.method == "GET" and "tree" in r.pretty_url:
7551
+ return
7552
+ if r.method == "POST" and "completion" in r.path:
7553
+ cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7554
+ cid = cm.group(1) if cm else ""
7555
+ # Incremental parse: scan complete SSE lines for the small governance
7556
+ # signals AS THEY STREAM and keep only the extracted results \u2014 never
7557
+ # buffer the body. The carry holds at most one partial line (bounded),
7558
+ # so there's NO cap on total stream length and no source is dropped.
7559
+ st = {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7560
+ def _scan(line, _st=st):
7561
+ if _st["usage"] is None:
7562
+ um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7563
+ if um:
7564
+ _st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7565
+ if len(_st["sources"]) < 50:
7566
+ for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7567
+ d = sm.group(1)
7568
+ if d and d not in _st["seen"] and len(_st["sources"]) < 50:
7569
+ _st["seen"].add(d)
7570
+ _st["sources"].append({"domain": d, "name": sm.group(2)})
7571
+ # Citations: the specific pages the model cited (title + url +
7572
+ # domain) \u2014 the real "tool result" for web search.
7573
+ if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
7574
+ try:
7575
+ cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7576
+ url = cit.get("url", "")
7577
+ if url and url not in _st["cseen"]:
7578
+ _st["cseen"].add(url)
7579
+ md = cit.get("metadata") or {}
7580
+ _st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7581
+ except Exception:
7582
+ pass
7583
+ def _tap(data, _st=st, _cid=cid):
7584
+ if data:
7585
+ try:
7586
+ txt = _st["carry"] + data.decode("utf-8", "replace")
7587
+ except Exception:
7588
+ txt = _st["carry"]
7589
+ lines = txt.split("\\n")
7590
+ _st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
7591
+ for ln in lines[:-1]:
7592
+ _scan(ln)
7593
+ else: # end of stream \u2014 flush carry + publish
7594
+ _scan(_st["carry"])
7595
+ if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
7596
+ with _sse_lock:
7597
+ _sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
7598
+ return data
7599
+ flow.response.stream = _tap
7600
+ return
7601
+ if "claude.ai" in (r.pretty_host or "") and "mcp" in r.path and "bootstrap" in r.path:
7602
+ return # buffer the MCP registry bootstrap (parsed in response())
7603
+ flow.response.stream = True
7604
+
7605
+ def response(self, flow):
7606
+ r = flow.request
7607
+ if "claude.ai" not in (r.pretty_host or ""): return
7608
+ # MCP server registry from the bootstrap SSE stream.
7609
+ if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7610
+ try:
7611
+ servers = {}
7612
+ for line in (flow.response.get_text() or "").split("\\n"):
7613
+ if not line.startswith("data: "): continue
7614
+ try: dd = json.loads(line[6:])
7615
+ except: continue
7616
+ if isinstance(dd.get("servers"), list):
7617
+ for s in dd["servers"]:
7618
+ sid = s.get("uuid", "")
7619
+ if sid: servers[sid] = {"name": s.get("name", ""), "url": s.get("url", ""), "connected": False, "tools": []}
7620
+ if "uuid" in dd and "connected" in dd and dd.get("uuid") in servers:
7621
+ servers[dd["uuid"]]["connected"] = dd.get("connected", False)
7622
+ if "server_uuid" in dd and isinstance(dd.get("tools"), list) and dd.get("server_uuid") in servers:
7623
+ for t in dd["tools"]:
7624
+ servers[dd["server_uuid"]]["tools"].append({"name": t.get("name", ""), "description": (t.get("description") or "")[:200]})
7625
+ if servers:
7626
+ with _mcp_lock:
7627
+ _mcp_registry.clear()
7628
+ _mcp_registry.update(servers)
7629
+ print("[synkro] MCP servers: " + str(len(servers)))
7630
+ except Exception:
7631
+ pass
7632
+ return
7633
+ if not ("chat_conversations" in r.path and r.method == "GET" and "tree" in r.pretty_url and flow.response): return
7634
+ if not CAPTURE_URL or not TOKEN: return
7635
+ try: convo = json.loads(flow.response.get_text())
7636
+ except: return
7637
+ m = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7638
+ convo_id = m.group(1) if m else ""
7639
+ model = convo.get("model", "unknown")
7640
+ settings = convo.get("settings") or {}
7641
+ emt = settings.get("enabled_mcp_tools")
7642
+ enabled_mcp = list(emt.keys()) if isinstance(emt, dict) else []
7643
+ web_search_on = bool(settings.get("enabled_web_search", False))
7644
+ platform = convo.get("platform", "")
7645
+ all_msgs = convo.get("chat_messages", [])
7646
+ if not all_msgs: return
7647
+ # Current turn only: last human message and everything after it.
7648
+ last_human = -1
7649
+ for i in range(len(all_msgs) - 1, -1, -1):
7650
+ if all_msgs[i].get("sender") == "human": last_human = i; break
7651
+ chat = all_msgs[last_human:] if last_human >= 0 else all_msgs[-2:]
7652
+ messages = []
7653
+ for msg in chat:
7654
+ sender = msg.get("sender", "")
7655
+ role = "user" if sender == "human" else "assistant"
7656
+ blocks = msg.get("content", [])
7657
+ text_parts = []
7658
+ tool_calls = []
7659
+ tool_results = []
7660
+ thinking = ""
7661
+ if isinstance(blocks, list):
7662
+ for b in blocks:
7663
+ if isinstance(b, str): text_parts.append(b); continue
7664
+ if not isinstance(b, dict): continue
7665
+ bt = b.get("type", "")
7666
+ if bt == "text":
7667
+ text_parts.append(b.get("text", ""))
7668
+ elif bt == "thinking":
7669
+ thinking += b.get("thinking", "") or b.get("text", "")
7670
+ elif bt == "tool_use" or bt == "server_tool_use":
7671
+ tool_calls.append({"name": b.get("name", ""), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
7672
+ elif bt.endswith("_tool_result") or bt == "tool_result":
7673
+ rc = b.get("content", "")
7674
+ if isinstance(rc, list):
7675
+ rparts = []
7676
+ for x in rc:
7677
+ if not isinstance(x, dict): continue
7678
+ if x.get("type") == "web_search_result":
7679
+ sn = x.get("page_content", "") or x.get("snippet", "") or x.get("content", "") or ""
7680
+ if isinstance(sn, dict): sn = sn.get("text", "")
7681
+ rparts.append((x.get("title", "") + " (" + x.get("url", "") + ")" + ((" - " + str(sn)[:200]) if sn else "")).strip())
7682
+ elif x.get("text"):
7683
+ rparts.append(x["text"])
7684
+ elif isinstance(x.get("content"), str):
7685
+ rparts.append(x["content"])
7686
+ rc = " | ".join(p for p in rparts if p and p.strip())
7687
+ dc = b.get("display_content")
7688
+ if isinstance(dc, dict):
7689
+ rc = rc or dc.get("json_block", "") or dc.get("text", "")
7690
+ # web_search results are server-side (content:[]); we surface the
7691
+ # sources via sse_meta instead. Only record a non-empty result.
7692
+ if rc and str(rc).strip():
7693
+ tool_results.append({"name": b.get("name", ""), "content": str(rc)})
7694
+ elif isinstance(blocks, str):
7695
+ text_parts.append(blocks)
7696
+ text = " ".join(p for p in text_parts if p).strip()
7697
+ if role == "assistant":
7698
+ am = {"role": "assistant", "content": text}
7699
+ if tool_calls: am["tool_calls"] = tool_calls
7700
+ if thinking: am["thinking"] = thinking
7701
+ if text or tool_calls or thinking: messages.append(am)
7702
+ for tr in tool_results:
7703
+ messages.append({"role": "tool", "content": tr["content"], "name": tr.get("name", "")})
7704
+ elif role == "user" and text:
7705
+ messages.append({"role": "user", "content": text})
7706
+ if not messages: return
7707
+ data = {"request": {"model": model, "messages": messages}, "conversation_id": convo_id}
7708
+ meta = {}
7709
+ if platform: meta["platform"] = platform
7710
+ if web_search_on: meta["web_search_enabled"] = True
7711
+ if enabled_mcp: meta["enabled_mcp_tools"] = enabled_mcp[:20]
7712
+ if meta: data["conversation_metadata"] = meta
7713
+ with _sse_lock:
7714
+ sm = _sse_meta.pop(convo_id, None)
7715
+ if sm: data["sse_meta"] = sm
7716
+ with _mcp_lock:
7717
+ reg = list(_mcp_registry.values())
7718
+ if reg: data["mcp_servers"] = reg
7719
+ ntools = 0
7720
+ for mm in messages:
7721
+ ntools += len(mm.get("tool_calls") or [])
7722
+ nsrc = len((sm or {}).get("sources") or [])
7723
+ print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7724
+ def send(_d=data):
7725
+ try:
7726
+ req = urllib.request.Request(CAPTURE_URL, data=json.dumps(_d).encode(), headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7727
+ urllib.request.urlopen(req, timeout=5)
7728
+ except Exception as e:
7729
+ print("[synkro] capture error: " + str(e))
7730
+ threading.Thread(target=send, daemon=True).start()
7731
+
7732
+ addons = [SynkroTap()]
7733
+ `;
7734
+ MCP_PROXY_PY = `#!/usr/bin/env python3
7735
+ """Synkro MCP Proxy (local) \u2014 stdio passthrough that taps MCP tool calls."""
7736
+ import sys, os, json, subprocess, threading, urllib.request, time
7737
+
7738
+ CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7739
+ TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7740
+ SERVER_NAME = os.environ.get("SYNKRO_MCP_SERVER", "unknown")
7741
+
7742
+ pending = {}
7743
+ pending_lock = threading.Lock()
7744
+
7745
+ def send_capture(tool_name, tool_args, tool_result, latency_ms):
7746
+ if not CAPTURE_URL or not TOKEN: return
7747
+ try:
7748
+ payload = json.dumps({
7749
+ "request": {
7750
+ "model": "mcp:" + SERVER_NAME,
7751
+ "messages": [
7752
+ {"role": "assistant", "content": "", "tool_calls": [{"name": tool_name, "arguments": json.dumps(tool_args)}]},
7753
+ {"role": "tool", "content": json.dumps(tool_result), "name": tool_name},
7754
+ ]
7755
+ },
7756
+ "mcp": {"server": SERVER_NAME, "tool": tool_name, "latency_ms": latency_ms}
7757
+ }).encode()
7758
+ req = urllib.request.Request(CAPTURE_URL, data=payload, headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7759
+ urllib.request.urlopen(req, timeout=5)
7760
+ except Exception as e:
7761
+ print("[synkro-mcp] capture error: " + str(e), file=sys.stderr)
7762
+
7763
+ def tap_message(data, direction):
7764
+ if not isinstance(data, dict): return
7765
+ method = data.get("method")
7766
+ msg_id = data.get("id")
7767
+ if direction == "request" and method == "tools/call":
7768
+ params = data.get("params", {})
7769
+ tool_name = params.get("name", "unknown")
7770
+ tool_args = params.get("arguments", {})
7771
+ if msg_id is not None:
7772
+ with pending_lock:
7773
+ if len(pending) < 1000:
7774
+ pending[msg_id] = {"name": tool_name, "args": tool_args, "start": time.time()}
7775
+ print("[synkro-mcp] " + SERVER_NAME + "." + str(tool_name), file=sys.stderr)
7776
+ if direction == "response" and msg_id is not None:
7777
+ with pending_lock:
7778
+ call = pending.pop(msg_id, None)
7779
+ if call:
7780
+ latency_ms = int((time.time() - call["start"]) * 1000)
7781
+ result = data.get("result", data.get("error", {}))
7782
+ threading.Thread(target=send_capture, args=(call["name"], call["args"], result, latency_ms), daemon=True).start()
7783
+
7784
+ def read_jsonrpc(stream):
7785
+ buf = b""
7786
+ while True:
7787
+ chunk = stream.read(1)
7788
+ if not chunk: return None
7789
+ if len(buf) < 5000000: # bound the line buffer (CWE-400)
7790
+ buf += chunk
7791
+ if chunk == b"\\n":
7792
+ line = buf.strip()
7793
+ buf = b""
7794
+ if not line: continue
7795
+ try: return json.loads(line)
7796
+ except: continue
7797
+
7798
+ def forward_stdin(proc):
7799
+ while True:
7800
+ msg = read_jsonrpc(sys.stdin.buffer)
7801
+ if msg is None: break
7802
+ tap_message(msg, "request")
7803
+ line = json.dumps(msg) + "\\n"
7804
+ try:
7805
+ proc.stdin.write(line.encode())
7806
+ proc.stdin.flush()
7807
+ except: break
7808
+
7809
+ def main():
7810
+ args = sys.argv[1:]
7811
+ if "--" in args:
7812
+ sep = args.index("--")
7813
+ args = args[sep + 1:]
7814
+ if not args:
7815
+ print("Usage: mcp_proxy.py -- <command> [args...]", file=sys.stderr)
7816
+ sys.exit(1)
7817
+ # Spawn the user's own configured MCP server (from claude_desktop_config.json).
7818
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr)
7819
+ t = threading.Thread(target=forward_stdin, args=(proc,), daemon=True)
7820
+ t.start()
7821
+ while True:
7822
+ msg = read_jsonrpc(proc.stdout)
7823
+ if msg is None: break
7824
+ tap_message(msg, "response")
7825
+ line = json.dumps(msg) + "\\n"
7826
+ try:
7827
+ sys.stdout.buffer.write(line.encode())
7828
+ sys.stdout.buffer.flush()
7829
+ except: break
7830
+ proc.wait()
7831
+ sys.exit(proc.returncode)
7832
+
7833
+ if __name__ == "__main__":
7834
+ main()
7835
+ `;
7836
+ MCP_PATCH_PY = `import json, sys
7837
+ config_path, proxy_path, capture_url, token = sys.argv[1:5]
7838
+ with open(config_path) as f: config = json.load(f)
7839
+ servers = config.get("mcpServers", {})
7840
+ if not servers:
7841
+ print("No MCP servers configured")
7842
+ sys.exit(1)
7843
+ patched = 0
7844
+ for name, server in servers.items():
7845
+ if server.get("_synkro_original"): continue
7846
+ orig_cmd = server.get("command", "")
7847
+ orig_args = server.get("args", [])
7848
+ if not orig_cmd: continue
7849
+ server["_synkro_original"] = {"command": orig_cmd, "args": orig_args}
7850
+ server["command"] = "python3"
7851
+ server["args"] = [proxy_path, "--", orig_cmd] + orig_args
7852
+ env = server.get("env", {})
7853
+ env["SYNKRO_CAPTURE_URL"] = capture_url
7854
+ env["SYNKRO_TAP_TOKEN"] = token
7855
+ env["SYNKRO_MCP_SERVER"] = name
7856
+ server["env"] = env
7857
+ patched += 1
7858
+ with open(config_path, "w") as f: json.dump(config, f, indent=2)
7859
+ print("Patched " + str(patched) + " MCP servers")
7860
+ `;
7861
+ }
7862
+ });
7863
+
7344
7864
  // cli/local-cc/macKeychain.ts
7345
7865
  var macKeychain_exports = {};
7346
7866
  __export(macKeychain_exports, {
@@ -7363,9 +7883,9 @@ __export(macKeychain_exports, {
7363
7883
  writeCursorApiKey: () => writeCursorApiKey,
7364
7884
  writeRefreshAgent: () => writeRefreshAgent
7365
7885
  });
7366
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync5, statSync } from "fs";
7367
- import { homedir as homedir5, platform as platform2 } from "os";
7368
- import { join as join5 } from "path";
7886
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
7887
+ import { homedir as homedir6, platform as platform2 } from "os";
7888
+ import { join as join6 } from "path";
7369
7889
  import { spawnSync } from "child_process";
7370
7890
  function needsKeychainBridge() {
7371
7891
  return platform2() === "darwin";
@@ -7385,13 +7905,13 @@ function exportKeychainCreds() {
7385
7905
  if (!blob) return null;
7386
7906
  mkdirSync6(CLAUDE_CREDS_DIR, { recursive: true });
7387
7907
  chmodSync(CLAUDE_CREDS_DIR, 448);
7388
- writeFileSync6(CLAUDE_CREDS_FILE, blob, "utf-8");
7908
+ writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
7389
7909
  chmodSync(CLAUDE_CREDS_FILE, 384);
7390
7910
  return CLAUDE_CREDS_FILE;
7391
7911
  }
7392
7912
  function cursorApiKeyConfigured() {
7393
7913
  try {
7394
- return existsSync7(CURSOR_API_KEY_FILE) && readFileSync5(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
7914
+ return existsSync8(CURSOR_API_KEY_FILE) && readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
7395
7915
  } catch {
7396
7916
  return false;
7397
7917
  }
@@ -7401,13 +7921,13 @@ function writeCursorApiKey(key) {
7401
7921
  if (!trimmed) return;
7402
7922
  mkdirSync6(CURSOR_CREDS_DIR, { recursive: true });
7403
7923
  chmodSync(CURSOR_CREDS_DIR, 448);
7404
- writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
7924
+ writeFileSync7(CURSOR_API_KEY_FILE, trimmed, "utf-8");
7405
7925
  chmodSync(CURSOR_API_KEY_FILE, 384);
7406
7926
  }
7407
7927
  async function validateCursorApiKey() {
7408
7928
  let key;
7409
7929
  try {
7410
- key = readFileSync5(CURSOR_API_KEY_FILE, "utf-8").trim();
7930
+ key = readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim();
7411
7931
  } catch {
7412
7932
  return null;
7413
7933
  }
@@ -7426,7 +7946,7 @@ async function validateCursorApiKey() {
7426
7946
  }
7427
7947
  }
7428
7948
  function credsAreStale() {
7429
- if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
7949
+ if (!existsSync8(CLAUDE_CREDS_FILE)) return true;
7430
7950
  try {
7431
7951
  const ageMs = Date.now() - statSync(CLAUDE_CREDS_FILE).mtimeMs;
7432
7952
  return ageMs > REFRESH_INTERVAL_SECONDS * 1e3;
@@ -7438,7 +7958,7 @@ function writeRefreshAgent(synkroBinPath) {
7438
7958
  if (platform2() !== "darwin") {
7439
7959
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
7440
7960
  }
7441
- mkdirSync6(join5(homedir5(), "Library", "LaunchAgents"), { recursive: true });
7961
+ mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
7442
7962
  const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
7443
7963
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
7444
7964
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -7457,13 +7977,13 @@ function writeRefreshAgent(synkroBinPath) {
7457
7977
  <key>RunAtLoad</key>
7458
7978
  <true/>
7459
7979
  <key>StandardErrorPath</key>
7460
- <string>${join5(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7980
+ <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7461
7981
  <key>StandardOutPath</key>
7462
- <string>${join5(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7982
+ <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7463
7983
  </dict>
7464
7984
  </plist>
7465
7985
  `;
7466
- writeFileSync6(LAUNCHD_PLIST, plist, "utf-8");
7986
+ writeFileSync7(LAUNCHD_PLIST, plist, "utf-8");
7467
7987
  return LAUNCHD_PLIST;
7468
7988
  }
7469
7989
  function loadRefreshAgent() {
@@ -7489,7 +8009,7 @@ function uninstallRefreshAgent() {
7489
8009
  timeout: 5e3
7490
8010
  });
7491
8011
  try {
7492
- if (existsSync7(LAUNCHD_PLIST)) {
8012
+ if (existsSync8(LAUNCHD_PLIST)) {
7493
8013
  __require("fs").unlinkSync(LAUNCHD_PLIST);
7494
8014
  }
7495
8015
  } catch {
@@ -7501,7 +8021,7 @@ function refreshCreds() {
7501
8021
  }
7502
8022
  function readExportedCreds() {
7503
8023
  try {
7504
- return readFileSync5(CLAUDE_CREDS_FILE, "utf-8");
8024
+ return readFileSync6(CLAUDE_CREDS_FILE, "utf-8");
7505
8025
  } catch {
7506
8026
  return null;
7507
8027
  }
@@ -7510,14 +8030,14 @@ var SYNKRO_DIR, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, CURSOR_CREDS_DIR, CURSOR_AP
7510
8030
  var init_macKeychain = __esm({
7511
8031
  "cli/local-cc/macKeychain.ts"() {
7512
8032
  "use strict";
7513
- SYNKRO_DIR = join5(homedir5(), ".synkro");
7514
- CLAUDE_CREDS_DIR = join5(SYNKRO_DIR, "claude-creds");
7515
- CLAUDE_CREDS_FILE = join5(CLAUDE_CREDS_DIR, ".credentials.json");
7516
- CURSOR_CREDS_DIR = join5(SYNKRO_DIR, "cursor-creds");
7517
- CURSOR_API_KEY_FILE = join5(CURSOR_CREDS_DIR, "api-key");
8033
+ SYNKRO_DIR = join6(homedir6(), ".synkro");
8034
+ CLAUDE_CREDS_DIR = join6(SYNKRO_DIR, "claude-creds");
8035
+ CLAUDE_CREDS_FILE = join6(CLAUDE_CREDS_DIR, ".credentials.json");
8036
+ CURSOR_CREDS_DIR = join6(SYNKRO_DIR, "cursor-creds");
8037
+ CURSOR_API_KEY_FILE = join6(CURSOR_CREDS_DIR, "api-key");
7518
8038
  KEYCHAIN_SERVICE = "Claude Code-credentials";
7519
8039
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
7520
- LAUNCHD_PLIST = join5(homedir5(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
8040
+ LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
7521
8041
  REFRESH_INTERVAL_SECONDS = 45 * 60;
7522
8042
  KeychainExportError = class extends Error {
7523
8043
  constructor(message, cause) {
@@ -7554,9 +8074,9 @@ __export(dockerInstall_exports, {
7554
8074
  waitForContainerReady: () => waitForContainerReady,
7555
8075
  waitForWorkersReady: () => waitForWorkersReady
7556
8076
  });
7557
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
7558
- import { homedir as homedir6 } from "os";
7559
- import { join as join6 } from "path";
8077
+ import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
8078
+ import { homedir as homedir7 } from "os";
8079
+ import { join as join7 } from "path";
7560
8080
  import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
7561
8081
  function splitWorkers(total, providers) {
7562
8082
  const t = Math.max(0, Math.floor(total));
@@ -7651,9 +8171,9 @@ function readSynkroFileConfig() {
7651
8171
  try {
7652
8172
  const root = execSync4("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
7653
8173
  if (!root) return { pool: "auto", warnings: [] };
7654
- const fp = join6(root, "synkro.toml");
7655
- if (!existsSync8(fp)) return { pool: "auto", warnings: [] };
7656
- const parsed = parseSynkroToml(readFileSync6(fp, "utf-8"));
8174
+ const fp = join7(root, "synkro.toml");
8175
+ if (!existsSync9(fp)) return { pool: "auto", warnings: [] };
8176
+ const parsed = parseSynkroToml(readFileSync7(fp, "utf-8"));
7657
8177
  return resolveGraderPool(parsed);
7658
8178
  } catch {
7659
8179
  }
@@ -7728,7 +8248,7 @@ function assertDockerAvailable() {
7728
8248
  }
7729
8249
  function claudeCredsHostDir() {
7730
8250
  if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
7731
- return join6(homedir6(), ".claude");
8251
+ return join7(homedir7(), ".claude");
7732
8252
  }
7733
8253
  function resolveSynkroBin() {
7734
8254
  const which2 = spawnSync2("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
@@ -7784,11 +8304,11 @@ async function dockerInstall(opts = {}) {
7784
8304
  mkdirSync7(PGDATA_PATH, { recursive: true });
7785
8305
  mkdirSync7(BACKUP_DIR, { recursive: true });
7786
8306
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
7787
- const hostClaudeJson = join6(homedir6(), ".claude.json");
7788
- if (existsSync8(hostClaudeJson)) {
8307
+ const hostClaudeJson = join7(homedir7(), ".claude.json");
8308
+ if (existsSync9(hostClaudeJson)) {
7789
8309
  copyFileSync(hostClaudeJson, CLAUDE_HOST_STATE_FILE);
7790
8310
  }
7791
- if (!existsSync8(MCP_JWT_PATH)) {
8311
+ if (!existsSync9(MCP_JWT_PATH)) {
7792
8312
  throw new DockerInstallError(
7793
8313
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
7794
8314
  );
@@ -7816,7 +8336,7 @@ async function dockerInstall(opts = {}) {
7816
8336
  console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
7817
8337
  }
7818
8338
  } else {
7819
- mkdirSync7(join6(homedir6(), ".claude"), { recursive: true });
8339
+ mkdirSync7(join7(homedir7(), ".claude"), { recursive: true });
7820
8340
  }
7821
8341
  const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
7822
8342
  const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
@@ -7869,7 +8389,7 @@ async function dockerInstall(opts = {}) {
7869
8389
  "-v",
7870
8390
  `${credsDir}:/home/synkro/.claude:rw`,
7871
8391
  "-v",
7872
- `${join6(homedir6(), ".claude")}:/data/claude-host:ro`,
8392
+ `${join7(homedir7(), ".claude")}:/data/claude-host:ro`,
7873
8393
  "-v",
7874
8394
  `${CLAUDE_HOST_STATE_DIR}:/data/claude-host-state:ro`,
7875
8395
  // Cursor creds — mounted RW so the in-container refresher can rotate the
@@ -8049,7 +8569,7 @@ async function dockerSafeStart() {
8049
8569
  return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
8050
8570
  }
8051
8571
  const pgCheck = checkPgdata();
8052
- if (existsSync8(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
8572
+ if (existsSync9(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
8053
8573
  if (pgCheck.healthy) {
8054
8574
  console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
8055
8575
  } else {
@@ -8089,7 +8609,7 @@ async function dockerSafeRestart() {
8089
8609
  return { ok: startResult.ok, stop: stopResult, start: startResult };
8090
8610
  }
8091
8611
  function checkPgdata() {
8092
- if (!existsSync8(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
8612
+ if (!existsSync9(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
8093
8613
  const entries = readdirSync2(PGDATA_PATH);
8094
8614
  if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
8095
8615
  const hasPidFile = entries.includes("postmaster.pid");
@@ -8106,11 +8626,11 @@ var init_dockerInstall = __esm({
8106
8626
  "use strict";
8107
8627
  init_agentDetect();
8108
8628
  init_macKeychain();
8109
- SYNKRO_DIR2 = join6(homedir6(), ".synkro");
8110
- MCP_JWT_PATH = join6(SYNKRO_DIR2, ".mcp-jwt");
8111
- PGDATA_PATH = join6(SYNKRO_DIR2, "pgdata");
8112
- CLAUDE_HOST_STATE_DIR = join6(SYNKRO_DIR2, "claude-host-state");
8113
- CLAUDE_HOST_STATE_FILE = join6(CLAUDE_HOST_STATE_DIR, ".claude.json");
8629
+ SYNKRO_DIR2 = join7(homedir7(), ".synkro");
8630
+ MCP_JWT_PATH = join7(SYNKRO_DIR2, ".mcp-jwt");
8631
+ PGDATA_PATH = join7(SYNKRO_DIR2, "pgdata");
8632
+ CLAUDE_HOST_STATE_DIR = join7(SYNKRO_DIR2, "claude-host-state");
8633
+ CLAUDE_HOST_STATE_FILE = join7(CLAUDE_HOST_STATE_DIR, ".claude.json");
8114
8634
  HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
8115
8635
  HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
8116
8636
  HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
@@ -8125,7 +8645,7 @@ var init_dockerInstall = __esm({
8125
8645
  }
8126
8646
  cause;
8127
8647
  };
8128
- BACKUP_DIR = join6(SYNKRO_DIR2, "pgdata-backups");
8648
+ BACKUP_DIR = join7(SYNKRO_DIR2, "pgdata-backups");
8129
8649
  }
8130
8650
  });
8131
8651
 
@@ -8138,14 +8658,14 @@ __export(setupGithub_exports, {
8138
8658
  import { createInterface as createInterface2 } from "readline/promises";
8139
8659
  import { stdin as input, stdout as output } from "process";
8140
8660
  import { execSync as execSync5, spawn as nodeSpawn } from "child_process";
8141
- import { existsSync as existsSync9, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
8142
- import { homedir as homedir7, platform as platform3 } from "os";
8143
- import { join as join7 } from "path";
8661
+ import { existsSync as existsSync10, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
8662
+ import { homedir as homedir8, platform as platform3 } from "os";
8663
+ import { join as join8 } from "path";
8144
8664
  import { execFile as execFile2 } from "child_process";
8145
8665
  function readConfig() {
8146
- if (!existsSync9(CONFIG_PATH)) return {};
8666
+ if (!existsSync10(CONFIG_PATH)) return {};
8147
8667
  const out = {};
8148
- for (const line of readFileSync7(CONFIG_PATH, "utf-8").split("\n")) {
8668
+ for (const line of readFileSync8(CONFIG_PATH, "utf-8").split("\n")) {
8149
8669
  const t = line.trim();
8150
8670
  if (!t || t.startsWith("#")) continue;
8151
8671
  const eq = t.indexOf("=");
@@ -8206,7 +8726,7 @@ function sleep(ms) {
8206
8726
  return new Promise((r) => setTimeout(r, ms));
8207
8727
  }
8208
8728
  function captureClaudeSetupToken() {
8209
- const tmpFile = join7(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
8729
+ const tmpFile = join8(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
8210
8730
  return new Promise((resolve4, reject) => {
8211
8731
  const proc = nodeSpawn("script", ["-q", tmpFile, "claude", "setup-token"], {
8212
8732
  stdio: "inherit"
@@ -8215,7 +8735,7 @@ function captureClaudeSetupToken() {
8215
8735
  proc.on("close", (code) => {
8216
8736
  let raw = "";
8217
8737
  try {
8218
- raw = readFileSync7(tmpFile, "utf-8");
8738
+ raw = readFileSync8(tmpFile, "utf-8");
8219
8739
  } catch (e) {
8220
8740
  reject(new Error(`Could not read script output file: ${e.message}`));
8221
8741
  return;
@@ -8487,8 +9007,8 @@ var init_setupGithub = __esm({
8487
9007
  "use strict";
8488
9008
  init_githubSetup();
8489
9009
  init_stub();
8490
- SYNKRO_DIR3 = join7(homedir7(), ".synkro");
8491
- CONFIG_PATH = join7(SYNKRO_DIR3, "config.env");
9010
+ SYNKRO_DIR3 = join8(homedir8(), ".synkro");
9011
+ CONFIG_PATH = join8(SYNKRO_DIR3, "config.env");
8492
9012
  }
8493
9013
  });
8494
9014
 
@@ -8502,16 +9022,16 @@ __export(install_exports, {
8502
9022
  syncSkillFiles: () => syncSkillFiles,
8503
9023
  writeHookScripts: () => writeHookScripts
8504
9024
  });
8505
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
8506
- import { homedir as homedir8 } from "os";
8507
- import { join as join8 } from "path";
9025
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
9026
+ import { homedir as homedir9 } from "os";
9027
+ import { join as join9 } from "path";
8508
9028
  import { execSync as execSync6 } from "child_process";
8509
9029
  import { createInterface as createInterface3 } from "readline";
8510
9030
  function resolvePersistedHookMode() {
8511
9031
  if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
8512
9032
  if (process.env.SYNKRO_HOOK_MODE === "stub") return "stub";
8513
9033
  try {
8514
- const env = readFileSync8(CONFIG_PATH2, "utf-8");
9034
+ const env = readFileSync9(CONFIG_PATH2, "utf-8");
8515
9035
  if (/^SYNKRO_HOOK_MODE=['"]?full['"]?\s*$/m.test(env)) return "full";
8516
9036
  } catch {
8517
9037
  }
@@ -8664,31 +9184,31 @@ function ensureSynkroDir() {
8664
9184
  mkdirSync8(HOOKS_DIR, { recursive: true });
8665
9185
  mkdirSync8(BIN_DIR, { recursive: true });
8666
9186
  mkdirSync8(OFFSETS_DIR, { recursive: true });
8667
- mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
9187
+ mkdirSync8(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
8668
9188
  }
8669
9189
  function writeHookScripts(mode = "stub") {
8670
- const installExtractCorePath = join8(HOOKS_DIR, "installExtractCore.ts");
8671
- const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
8672
- const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
8673
- const editPrecheckScriptPath = join8(HOOKS_DIR, "cc-edit-precheck.ts");
8674
- const cwePrecheckScriptPath = join8(HOOKS_DIR, "cc-cwe-precheck.ts");
8675
- const cvePrecheckScriptPath = join8(HOOKS_DIR, "cc-cve-precheck.ts");
8676
- const planJudgeScriptPath = join8(HOOKS_DIR, "cc-plan-judge.ts");
8677
- const agentJudgeScriptPath = join8(HOOKS_DIR, "cc-agent-judge.ts");
8678
- const stopSummaryScriptPath = join8(HOOKS_DIR, "cc-stop-summary.ts");
8679
- const sessionStartScriptPath = join8(HOOKS_DIR, "cc-session-start.ts");
8680
- const transcriptSyncScriptPath = join8(HOOKS_DIR, "cc-transcript-sync.ts");
8681
- const userPromptSubmitScriptPath = join8(HOOKS_DIR, "cc-user-prompt-submit.ts");
8682
- const commonScriptPath = join8(HOOKS_DIR, "_synkro-common.ts");
8683
- const commonBashScriptPath = join8(HOOKS_DIR, "_synkro-common.sh");
8684
- const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
8685
- const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
8686
- const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8687
- const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8688
- const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8689
- const taskActivateIntentScriptPath = join8(HOOKS_DIR, "cc-task-activate-intent.ts");
9190
+ const installExtractCorePath = join9(HOOKS_DIR, "installExtractCore.ts");
9191
+ const bashScriptPath = join9(HOOKS_DIR, "cc-bash-judge.ts");
9192
+ const bashFollowupScriptPath = join9(HOOKS_DIR, "cc-bash-followup.ts");
9193
+ const editPrecheckScriptPath = join9(HOOKS_DIR, "cc-edit-precheck.ts");
9194
+ const cwePrecheckScriptPath = join9(HOOKS_DIR, "cc-cwe-precheck.ts");
9195
+ const cvePrecheckScriptPath = join9(HOOKS_DIR, "cc-cve-precheck.ts");
9196
+ const planJudgeScriptPath = join9(HOOKS_DIR, "cc-plan-judge.ts");
9197
+ const agentJudgeScriptPath = join9(HOOKS_DIR, "cc-agent-judge.ts");
9198
+ const stopSummaryScriptPath = join9(HOOKS_DIR, "cc-stop-summary.ts");
9199
+ const sessionStartScriptPath = join9(HOOKS_DIR, "cc-session-start.ts");
9200
+ const transcriptSyncScriptPath = join9(HOOKS_DIR, "cc-transcript-sync.ts");
9201
+ const userPromptSubmitScriptPath = join9(HOOKS_DIR, "cc-user-prompt-submit.ts");
9202
+ const commonScriptPath = join9(HOOKS_DIR, "_synkro-common.ts");
9203
+ const commonBashScriptPath = join9(HOOKS_DIR, "_synkro-common.sh");
9204
+ const installScanScriptPath = join9(HOOKS_DIR, "cc-install-scan.ts");
9205
+ const cursorBashJudgePath = join9(HOOKS_DIR, "cursor-bash-judge.ts");
9206
+ const cursorEditCapturePath = join9(HOOKS_DIR, "cursor-edit-capture.ts");
9207
+ const cursorAgentCapturePath = join9(HOOKS_DIR, "cursor-agent-capture.ts");
9208
+ const mcpStdioProxyPath = join9(HOOKS_DIR, "mcp-stdio-proxy.ts");
9209
+ const taskActivateIntentScriptPath = join9(HOOKS_DIR, "cc-task-activate-intent.ts");
8690
9210
  if (mode === "stub") {
8691
- const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
9211
+ const stubCommonPath = join9(HOOKS_DIR, "_synkro-stub-common.ts");
8692
9212
  const stubFiles = [
8693
9213
  [stubCommonPath, STUB_COMMON_TS],
8694
9214
  [bashScriptPath, STUB_BASH_JUDGE_TS],
@@ -8709,14 +9229,14 @@ function writeHookScripts(mode = "stub") {
8709
9229
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
8710
9230
  ];
8711
9231
  for (const [p, content] of stubFiles) {
8712
- writeFileSync7(p, content, "utf-8");
9232
+ writeFileSync8(p, content, "utf-8");
8713
9233
  chmodSync2(p, 493);
8714
9234
  }
8715
- writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
9235
+ writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8716
9236
  chmodSync2(mcpStdioProxyPath, 493);
8717
9237
  for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
8718
9238
  try {
8719
- unlinkSync4(join8(HOOKS_DIR, stale));
9239
+ unlinkSync4(join9(HOOKS_DIR, stale));
8720
9240
  } catch {
8721
9241
  }
8722
9242
  }
@@ -8739,26 +9259,26 @@ function writeHookScripts(mode = "stub") {
8739
9259
  taskActivateIntentScript: taskActivateIntentScriptPath
8740
9260
  };
8741
9261
  }
8742
- writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
8743
- writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
8744
- writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
8745
- writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
8746
- writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
8747
- writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
8748
- writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
8749
- writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
8750
- writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
8751
- writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
8752
- writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
8753
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
8754
- writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
8755
- writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
8756
- writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
8757
- writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
8758
- writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8759
- writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8760
- writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
8761
- writeFileSync7(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
9262
+ writeFileSync8(bashScriptPath, BASH_JUDGE_TS, "utf-8");
9263
+ writeFileSync8(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
9264
+ writeFileSync8(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
9265
+ writeFileSync8(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
9266
+ writeFileSync8(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
9267
+ writeFileSync8(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
9268
+ writeFileSync8(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
9269
+ writeFileSync8(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
9270
+ writeFileSync8(sessionStartScriptPath, SESSION_START_TS, "utf-8");
9271
+ writeFileSync8(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
9272
+ writeFileSync8(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
9273
+ writeFileSync8(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
9274
+ writeFileSync8(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
9275
+ writeFileSync8(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
9276
+ writeFileSync8(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
9277
+ writeFileSync8(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
9278
+ writeFileSync8(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
9279
+ writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
9280
+ writeFileSync8(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
9281
+ writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
8762
9282
  chmodSync2(bashScriptPath, 493);
8763
9283
  chmodSync2(bashFollowupScriptPath, 493);
8764
9284
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8780,7 +9300,7 @@ function writeHookScripts(mode = "stub") {
8780
9300
  chmodSync2(installExtractCorePath, 493);
8781
9301
  chmodSync2(taskActivateIntentScriptPath, 493);
8782
9302
  try {
8783
- unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
9303
+ unlinkSync4(join9(HOOKS_DIR, "_synkro-stub-common.ts"));
8784
9304
  } catch {
8785
9305
  }
8786
9306
  return {
@@ -8811,11 +9331,11 @@ function shellQuoteSingle(value) {
8811
9331
  }
8812
9332
  function resolveSynkroBundle() {
8813
9333
  const scriptPath = process.argv[1];
8814
- if (scriptPath && existsSync10(scriptPath)) return scriptPath;
9334
+ if (scriptPath && existsSync11(scriptPath)) return scriptPath;
8815
9335
  return null;
8816
9336
  }
8817
9337
  function writeConfigEnv(opts) {
8818
- const credsPath = join8(SYNKRO_DIR4, "credentials.json");
9338
+ const credsPath = join9(SYNKRO_DIR4, "credentials.json");
8819
9339
  const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
8820
9340
  const safeUserId = sanitizeConfigValue(opts.userId);
8821
9341
  const safeOrgId = sanitizeConfigValue(opts.orgId);
@@ -8831,7 +9351,7 @@ function writeConfigEnv(opts) {
8831
9351
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8832
9352
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8833
9353
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8834
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.53")}`
9354
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.54")}`
8835
9355
  ];
8836
9356
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8837
9357
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8847,15 +9367,15 @@ function writeConfigEnv(opts) {
8847
9367
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
8848
9368
  lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "stub", 8))}`);
8849
9369
  lines.push("");
8850
- writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
9370
+ writeFileSync8(CONFIG_PATH2, lines.join("\n"), "utf-8");
8851
9371
  chmodSync2(CONFIG_PATH2, 384);
8852
9372
  }
8853
9373
  function resolveDeploymentMode() {
8854
9374
  const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE?.toLowerCase();
8855
9375
  if (envOverride === "bare-host" || envOverride === "docker") return envOverride;
8856
9376
  try {
8857
- if (existsSync10(CONFIG_PATH2)) {
8858
- const m = readFileSync8(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
9377
+ if (existsSync11(CONFIG_PATH2)) {
9378
+ const m = readFileSync9(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
8859
9379
  const val = m?.[1]?.toLowerCase();
8860
9380
  if (val === "bare-host" || val === "docker") return val;
8861
9381
  }
@@ -8882,16 +9402,16 @@ function collectLocalMetadata(includeClaudeCode = true) {
8882
9402
  meta.cc_version = execSync6("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
8883
9403
  } catch {
8884
9404
  }
8885
- const claudeDir = join8(homedir8(), ".claude");
9405
+ const claudeDir = join9(homedir9(), ".claude");
8886
9406
  try {
8887
- const settings = JSON.parse(readFileSync8(join8(claudeDir, "settings.json"), "utf-8"));
9407
+ const settings = JSON.parse(readFileSync9(join9(claudeDir, "settings.json"), "utf-8"));
8888
9408
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
8889
9409
  if (plugins.length) meta.enabled_plugins = plugins;
8890
9410
  if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
8891
9411
  } catch {
8892
9412
  }
8893
9413
  try {
8894
- const mcpCache = JSON.parse(readFileSync8(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
9414
+ const mcpCache = JSON.parse(readFileSync9(join9(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
8895
9415
  const mcpNames = Object.keys(mcpCache);
8896
9416
  if (mcpNames.length) meta.mcp_servers = mcpNames;
8897
9417
  } catch {
@@ -8903,10 +9423,10 @@ function collectLocalMetadata(includeClaudeCode = true) {
8903
9423
  } catch {
8904
9424
  }
8905
9425
  try {
8906
- const sessionsDir = join8(claudeDir, "sessions");
9426
+ const sessionsDir = join9(claudeDir, "sessions");
8907
9427
  const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
8908
9428
  for (const f of files) {
8909
- const s = JSON.parse(readFileSync8(join8(sessionsDir, f), "utf-8"));
9429
+ const s = JSON.parse(readFileSync9(join9(sessionsDir, f), "utf-8"));
8910
9430
  if (s.version) {
8911
9431
  meta.cc_version = meta.cc_version || s.version;
8912
9432
  break;
@@ -9081,9 +9601,9 @@ async function installCommand(opts = {}) {
9081
9601
  const scripts = writeHookScripts(hookMode);
9082
9602
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
9083
9603
  for (const mode of ["edit", "bash"]) {
9084
- const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
9604
+ const pidFile = join9(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
9085
9605
  try {
9086
- const pid = parseInt(readFileSync8(pidFile, "utf-8").trim(), 10);
9606
+ const pid = parseInt(readFileSync9(pidFile, "utf-8").trim(), 10);
9087
9607
  if (pid > 0) {
9088
9608
  process.kill(pid, "SIGTERM");
9089
9609
  console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
@@ -9163,7 +9683,7 @@ async function installCommand(opts = {}) {
9163
9683
  if (mintResp.ok) {
9164
9684
  const minted = await mintResp.json();
9165
9685
  mcpJwt = minted.token;
9166
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
9686
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
9167
9687
  } else {
9168
9688
  console.warn(" \u26A0 Could not mint MCP token \u2014 local server will reject requests until re-installed.");
9169
9689
  }
@@ -9191,7 +9711,7 @@ async function installCommand(opts = {}) {
9191
9711
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
9192
9712
  }
9193
9713
  const minted = await mintResp.json();
9194
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9714
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9195
9715
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
9196
9716
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
9197
9717
  console.log(` url: ${mcp.url}`);
@@ -9208,8 +9728,8 @@ async function installCommand(opts = {}) {
9208
9728
  if (hasCursor && !opts.noMcp) {
9209
9729
  try {
9210
9730
  if (useLocalMcp) {
9211
- const jwtPath = join8(SYNKRO_DIR4, ".mcp-jwt");
9212
- if (!existsSync10(jwtPath)) {
9731
+ const jwtPath = join9(SYNKRO_DIR4, ".mcp-jwt");
9732
+ if (!existsSync11(jwtPath)) {
9213
9733
  const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
9214
9734
  method: "POST",
9215
9735
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
@@ -9217,7 +9737,7 @@ async function installCommand(opts = {}) {
9217
9737
  });
9218
9738
  if (mintResp.ok) {
9219
9739
  const minted = await mintResp.json();
9220
- writeFileSync7(jwtPath, minted.token + "\n", { mode: 384 });
9740
+ writeFileSync8(jwtPath, minted.token + "\n", { mode: 384 });
9221
9741
  }
9222
9742
  }
9223
9743
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: "", local: true });
@@ -9237,7 +9757,7 @@ async function installCommand(opts = {}) {
9237
9757
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
9238
9758
  }
9239
9759
  const minted = await mintResp.json();
9240
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9760
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9241
9761
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: minted.token });
9242
9762
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
9243
9763
  console.log(` url: ${mcp.url}`);
@@ -9313,7 +9833,7 @@ async function installCommand(opts = {}) {
9313
9833
  const ready = await waitForContainerReady(6e4);
9314
9834
  if (ready) {
9315
9835
  console.log(" \u2713 container ready");
9316
- const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9836
+ const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9317
9837
  try {
9318
9838
  const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
9319
9839
  method: "POST",
@@ -9351,7 +9871,7 @@ async function installCommand(opts = {}) {
9351
9871
  try {
9352
9872
  let mcpToken = "";
9353
9873
  try {
9354
- mcpToken = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9874
+ mcpToken = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9355
9875
  } catch {
9356
9876
  }
9357
9877
  if (mcpToken) {
@@ -9432,6 +9952,17 @@ async function installCommand(opts = {}) {
9432
9952
  await setupGithubCommand2({ nonInteractive: true, githubToken: ghToken });
9433
9953
  }
9434
9954
  console.log("\u2713 Synkro installed.");
9955
+ if (process.stdin.isTTY && claudeDesktopInstalled()) {
9956
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
9957
+ const answer = await new Promise((resolve4) => rl.question("\nWould you like Claude Desktop observability? This routes Claude Desktop through a local proxy so its conversations appear in your dashboard (you can stop it anytime with Ctrl+C). [y/N]: ", resolve4));
9958
+ rl.close();
9959
+ if (/^y(es)?$/i.test(answer.trim())) {
9960
+ console.log("\nStarting Claude Desktop observability \u2014 press Ctrl+C to stop.\n");
9961
+ await runClaudeDesktopTap();
9962
+ } else {
9963
+ console.log(" Skipped. You can enable it later by re-running `synkro install`.");
9964
+ }
9965
+ }
9435
9966
  }
9436
9967
  function parseTomlValue2(raw) {
9437
9968
  const v = raw.trim();
@@ -9476,8 +10007,8 @@ function writeSynkroFileIfMissing(opts) {
9476
10007
  try {
9477
10008
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9478
10009
  if (!root) return;
9479
- if (root === homedir8()) return;
9480
- const fp = join8(root, "synkro.toml");
10010
+ if (root === homedir9()) return;
10011
+ const fp = join9(root, "synkro.toml");
9481
10012
  let hasFile = false;
9482
10013
  try {
9483
10014
  hasFile = statSync2(fp).isFile();
@@ -9516,7 +10047,7 @@ function writeSynkroFileIfMissing(opts) {
9516
10047
  "cve = true",
9517
10048
  ""
9518
10049
  ].join("\n");
9519
- writeFileSync7(fp, toml, "utf-8");
10050
+ writeFileSync8(fp, toml, "utf-8");
9520
10051
  console.log(` synkro.toml: wrote ${fp} (pool=${pool}, mode=${mode})`);
9521
10052
  } catch {
9522
10053
  }
@@ -9525,9 +10056,9 @@ function readFullSynkroFile() {
9525
10056
  try {
9526
10057
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9527
10058
  if (!root) return null;
9528
- const fp = join8(root, "synkro.toml");
9529
- if (!existsSync10(fp)) return null;
9530
- const parsed = parseSynkroToml2(readFileSync8(fp, "utf-8"));
10059
+ const fp = join9(root, "synkro.toml");
10060
+ if (!existsSync11(fp)) return null;
10061
+ const parsed = parseSynkroToml2(readFileSync9(fp, "utf-8"));
9531
10062
  const valid = ["claude-code", "cursor"];
9532
10063
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
9533
10064
  const resolved = resolveGraderPool(parsed);
@@ -9563,7 +10094,7 @@ function reconcileHarness() {
9563
10094
  console.log(`synkro.toml: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
9564
10095
  const scripts = writeHookScripts(resolvePersistedHookMode());
9565
10096
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9566
- const ccSettings = join8(homedir8(), ".claude", "settings.json");
10097
+ const ccSettings = join9(homedir9(), ".claude", "settings.json");
9567
10098
  if (wantCC) {
9568
10099
  installCCHooks(ccSettings, {
9569
10100
  bashJudgeScriptPath: scripts.bashScript,
@@ -9582,7 +10113,7 @@ function reconcileHarness() {
9582
10113
  });
9583
10114
  console.log(" \u2713 Claude Code hooks registered");
9584
10115
  try {
9585
- const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
10116
+ const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9586
10117
  if (mcpJwt) {
9587
10118
  installMcpConfig({ gatewayUrl: "", bearerToken: mcpJwt, local: true });
9588
10119
  console.log(" \u2713 Claude Code MCP registered");
@@ -9593,7 +10124,7 @@ function reconcileHarness() {
9593
10124
  if (uninstallCCHooks(ccSettings)) console.log(" \u2717 Claude Code hooks removed");
9594
10125
  if (uninstallMcpConfig()) console.log(" \u2717 Claude Code MCP removed");
9595
10126
  }
9596
- const cursorHooks = join8(homedir8(), ".cursor", "hooks.json");
10127
+ const cursorHooks = join9(homedir9(), ".cursor", "hooks.json");
9597
10128
  if (wantCursor) {
9598
10129
  installCursorHooks(cursorHooks, {
9599
10130
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
@@ -9646,7 +10177,7 @@ async function syncSkillFiles() {
9646
10177
  if (resolved.length === 0) return;
9647
10178
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
9648
10179
  const tasks = resolved.map((fp) => {
9649
- const content = readFileSync8(fp, "utf-8");
10180
+ const content = readFileSync9(fp, "utf-8");
9650
10181
  const source = `skill:${fp.split("/").pop()}`;
9651
10182
  if (!content.trim()) {
9652
10183
  console.log(` \u2298 skill ${source}: empty file, skipped`);
@@ -9692,17 +10223,17 @@ function detectGitRepo2() {
9692
10223
  function getClaudeProjectsFolder() {
9693
10224
  const cwd = process.cwd();
9694
10225
  const sanitized = "-" + cwd.replace(/\//g, "-");
9695
- const projectsDir = join8(homedir8(), ".claude", "projects", sanitized);
9696
- return existsSync10(projectsDir) ? projectsDir : null;
10226
+ const projectsDir = join9(homedir9(), ".claude", "projects", sanitized);
10227
+ return existsSync11(projectsDir) ? projectsDir : null;
9697
10228
  }
9698
10229
  function extractSessionInsights(projectsDir) {
9699
10230
  const insights = [];
9700
10231
  const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
9701
10232
  for (const file of files) {
9702
10233
  const sessionId = file.replace(".jsonl", "");
9703
- const filePath = join8(projectsDir, file);
10234
+ const filePath = join9(projectsDir, file);
9704
10235
  try {
9705
- const content = readFileSync8(filePath, "utf-8");
10236
+ const content = readFileSync9(filePath, "utf-8");
9706
10237
  const lines = content.split("\n").filter(Boolean);
9707
10238
  for (let i = 0; i < lines.length; i++) {
9708
10239
  try {
@@ -9781,14 +10312,14 @@ function cursorProjectSlug(workspaceRoot) {
9781
10312
  return workspaceRoot.replace(/^[/]+/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9782
10313
  }
9783
10314
  function getCursorTranscriptsDir() {
9784
- const dir = join8(homedir8(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
9785
- return existsSync10(dir) ? dir : null;
10315
+ const dir = join9(homedir9(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
10316
+ return existsSync11(dir) ? dir : null;
9786
10317
  }
9787
10318
  function isSafeConvId(id) {
9788
10319
  return /^[A-Za-z0-9_-]+$/.test(id);
9789
10320
  }
9790
10321
  function parseCursorTranscriptFile(filePath) {
9791
- const content = readFileSync8(filePath, "utf-8");
10322
+ const content = readFileSync9(filePath, "utf-8");
9792
10323
  const lines = content.split("\n").filter(Boolean);
9793
10324
  const messages = [];
9794
10325
  for (let i = 0; i < lines.length; i++) {
@@ -9820,8 +10351,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9820
10351
  for (let i = 0; i < convDirs.length; i++) {
9821
10352
  const convId = convDirs[i];
9822
10353
  if (!isSafeConvId(convId)) continue;
9823
- const filePath = join8(dir, convId, `${convId}.jsonl`);
9824
- if (!existsSync10(filePath)) continue;
10354
+ const filePath = join9(dir, convId, `${convId}.jsonl`);
10355
+ if (!existsSync11(filePath)) continue;
9825
10356
  try {
9826
10357
  const all = parseCursorTranscriptFile(filePath);
9827
10358
  const messages = all.length > 500 ? all.slice(-500) : all;
@@ -9843,8 +10374,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9843
10374
  process.stdout.write(`\r Progress: ${i + 1}/${convDirs.length} sessions (${totalMessages} messages embedded) `);
9844
10375
  }
9845
10376
  try {
9846
- const lc = readFileSync8(filePath, "utf-8").split("\n").filter(Boolean).length;
9847
- writeFileSync7(join8(OFFSETS_DIR, convId), String(lc), "utf-8");
10377
+ const lc = readFileSync9(filePath, "utf-8").split("\n").filter(Boolean).length;
10378
+ writeFileSync8(join9(OFFSETS_DIR, convId), String(lc), "utf-8");
9848
10379
  } catch {
9849
10380
  }
9850
10381
  }
@@ -9852,7 +10383,7 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9852
10383
  return { sessions: totalSessions, messages: totalMessages };
9853
10384
  }
9854
10385
  function parseTranscriptFile(filePath) {
9855
- const content = readFileSync8(filePath, "utf-8");
10386
+ const content = readFileSync9(filePath, "utf-8");
9856
10387
  const lines = content.split("\n").filter(Boolean);
9857
10388
  const messages = [];
9858
10389
  for (let i = 0; i < lines.length; i++) {
@@ -9900,7 +10431,7 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9900
10431
  for (let i = 0; i < files.length; i++) {
9901
10432
  const file = files[i];
9902
10433
  const sessionId = file.replace(".jsonl", "");
9903
- const filePath = join8(projectsDir, file);
10434
+ const filePath = join9(projectsDir, file);
9904
10435
  try {
9905
10436
  const allMessages = parseTranscriptFile(filePath);
9906
10437
  const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
@@ -9922,9 +10453,9 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9922
10453
  process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
9923
10454
  }
9924
10455
  try {
9925
- const content = readFileSync8(join8(projectsDir, file), "utf-8");
10456
+ const content = readFileSync9(join9(projectsDir, file), "utf-8");
9926
10457
  const lineCount = content.split("\n").filter(Boolean).length;
9927
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10458
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9928
10459
  } catch {
9929
10460
  }
9930
10461
  }
@@ -9945,7 +10476,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9945
10476
  const sessions = [];
9946
10477
  for (const file of batch) {
9947
10478
  const sessionId = file.replace(".jsonl", "");
9948
- const filePath = join8(projectsDir, file);
10479
+ const filePath = join9(projectsDir, file);
9949
10480
  try {
9950
10481
  const allMessages = parseTranscriptFile(filePath);
9951
10482
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -9974,11 +10505,11 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9974
10505
  }
9975
10506
  for (const file of batch) {
9976
10507
  const sessionId = file.replace(".jsonl", "");
9977
- const filePath = join8(projectsDir, file);
10508
+ const filePath = join9(projectsDir, file);
9978
10509
  try {
9979
- const content = readFileSync8(filePath, "utf-8");
10510
+ const content = readFileSync9(filePath, "utf-8");
9980
10511
  const lineCount = content.split("\n").filter(Boolean).length;
9981
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10512
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9982
10513
  } catch {
9983
10514
  }
9984
10515
  }
@@ -10001,11 +10532,12 @@ var init_install = __esm({
10001
10532
  init_repoConnect();
10002
10533
  init_projects();
10003
10534
  init_promptFetcher();
10535
+ init_claudeDesktopTap();
10004
10536
  init_dockerInstall();
10005
- SYNKRO_DIR4 = join8(homedir8(), ".synkro");
10006
- HOOKS_DIR = join8(SYNKRO_DIR4, "hooks");
10007
- BIN_DIR = join8(SYNKRO_DIR4, "bin");
10008
- CONFIG_PATH2 = join8(SYNKRO_DIR4, "config.env");
10537
+ SYNKRO_DIR4 = join9(homedir9(), ".synkro");
10538
+ HOOKS_DIR = join9(SYNKRO_DIR4, "hooks");
10539
+ BIN_DIR = join9(SYNKRO_DIR4, "bin");
10540
+ CONFIG_PATH2 = join9(SYNKRO_DIR4, "config.env");
10009
10541
  MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
10010
10542
  import { readFileSync } from 'node:fs';
10011
10543
  import { homedir } from 'node:os';
@@ -10116,21 +10648,21 @@ rl.on('line', async (line) => {
10116
10648
  }
10117
10649
  });
10118
10650
  `;
10119
- OFFSETS_DIR = join8(SYNKRO_DIR4, ".transcript-offsets");
10651
+ OFFSETS_DIR = join9(SYNKRO_DIR4, ".transcript-offsets");
10120
10652
  }
10121
10653
  });
10122
10654
 
10123
10655
  // cli/local-cc/install.ts
10124
- import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10125
- import { join as join9 } from "path";
10126
- import { homedir as homedir9 } from "os";
10656
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10657
+ import { join as join10 } from "path";
10658
+ import { homedir as homedir10 } from "os";
10127
10659
  import { spawnSync as spawnSync4 } from "child_process";
10128
10660
  function writePluginFiles() {
10129
10661
  for (const c of CHANNELS) {
10130
10662
  mkdirSync9(c.sessionDir, { recursive: true });
10131
10663
  mkdirSync9(c.pluginSettingsDir, { recursive: true });
10132
- writeFileSync8(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10133
- writeFileSync8(
10664
+ writeFileSync9(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10665
+ writeFileSync9(
10134
10666
  c.pluginSettingsPath,
10135
10667
  JSON.stringify({
10136
10668
  fastMode: true,
@@ -10138,7 +10670,7 @@ function writePluginFiles() {
10138
10670
  }, null, 2) + "\n",
10139
10671
  "utf-8"
10140
10672
  );
10141
- writeFileSync8(c.runScriptPath, c.runScriptSource, "utf-8");
10673
+ writeFileSync9(c.runScriptPath, c.runScriptSource, "utf-8");
10142
10674
  chmodSync3(c.runScriptPath, 493);
10143
10675
  }
10144
10676
  }
@@ -10157,10 +10689,10 @@ function runBunInstall() {
10157
10689
  }
10158
10690
  }
10159
10691
  function safelyMutateClaudeJson(mutator) {
10160
- if (!existsSync11(CLAUDE_JSON_PATH)) {
10692
+ if (!existsSync12(CLAUDE_JSON_PATH)) {
10161
10693
  return;
10162
10694
  }
10163
- const originalText = readFileSync9(CLAUDE_JSON_PATH, "utf-8");
10695
+ const originalText = readFileSync10(CLAUDE_JSON_PATH, "utf-8");
10164
10696
  let parsed;
10165
10697
  try {
10166
10698
  parsed = JSON.parse(originalText);
@@ -10192,7 +10724,7 @@ function safelyMutateClaudeJson(mutator) {
10192
10724
  copyFileSync2(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
10193
10725
  const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
10194
10726
  try {
10195
- writeFileSync8(tmpPath, newText, "utf-8");
10727
+ writeFileSync9(tmpPath, newText, "utf-8");
10196
10728
  const fd = openSync(tmpPath, "r");
10197
10729
  try {
10198
10730
  fsyncSync(fd);
@@ -10225,7 +10757,7 @@ function writeProjectMcpJson() {
10225
10757
  }
10226
10758
  }
10227
10759
  };
10228
- writeFileSync8(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10760
+ writeFileSync9(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10229
10761
  }
10230
10762
  }
10231
10763
  function patchClaudeJson() {
@@ -10302,42 +10834,42 @@ var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_S
10302
10834
  var init_install2 = __esm({
10303
10835
  "cli/local-cc/install.ts"() {
10304
10836
  "use strict";
10305
- CLAUDE_JSON_BACKUP_PATH = join9(homedir9(), ".claude.json.synkro-bak");
10306
- SESSION_DIR = join9(homedir9(), ".synkro", "cc_sessions");
10307
- PLUGIN_PATH = join9(SESSION_DIR, "synkro-channel.ts");
10308
- PLUGIN_PKG_PATH = join9(SESSION_DIR, "package.json");
10309
- PLUGIN_SETTINGS_DIR = join9(SESSION_DIR, ".claude");
10310
- PLUGIN_SETTINGS_PATH = join9(PLUGIN_SETTINGS_DIR, "settings.json");
10311
- PROJECT_MCP_PATH = join9(SESSION_DIR, ".mcp.json");
10312
- CLAUDE_JSON_PATH = join9(homedir9(), ".claude.json");
10313
- RUN_SCRIPT_PATH = join9(SESSION_DIR, "run-claude.sh");
10837
+ CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
10838
+ SESSION_DIR = join10(homedir10(), ".synkro", "cc_sessions");
10839
+ PLUGIN_PATH = join10(SESSION_DIR, "synkro-channel.ts");
10840
+ PLUGIN_PKG_PATH = join10(SESSION_DIR, "package.json");
10841
+ PLUGIN_SETTINGS_DIR = join10(SESSION_DIR, ".claude");
10842
+ PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
10843
+ PROJECT_MCP_PATH = join10(SESSION_DIR, ".mcp.json");
10844
+ CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
10845
+ RUN_SCRIPT_PATH = join10(SESSION_DIR, "run-claude.sh");
10314
10846
  TMUX_SESSION_NAME = "synkro-local-cc";
10315
10847
  CHANNEL_1_PORT = 8941;
10316
- SESSION_DIR_2 = join9(homedir9(), ".synkro", "cc_sessions_2");
10317
- PLUGIN_PATH_2 = join9(SESSION_DIR_2, "synkro-channel.ts");
10318
- PLUGIN_PKG_PATH_2 = join9(SESSION_DIR_2, "package.json");
10319
- PLUGIN_SETTINGS_DIR_2 = join9(SESSION_DIR_2, ".claude");
10320
- PLUGIN_SETTINGS_PATH_2 = join9(PLUGIN_SETTINGS_DIR_2, "settings.json");
10321
- PROJECT_MCP_PATH_2 = join9(SESSION_DIR_2, ".mcp.json");
10322
- RUN_SCRIPT_PATH_2 = join9(SESSION_DIR_2, "run-claude.sh");
10848
+ SESSION_DIR_2 = join10(homedir10(), ".synkro", "cc_sessions_2");
10849
+ PLUGIN_PATH_2 = join10(SESSION_DIR_2, "synkro-channel.ts");
10850
+ PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_2, "package.json");
10851
+ PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_2, ".claude");
10852
+ PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
10853
+ PROJECT_MCP_PATH_2 = join10(SESSION_DIR_2, ".mcp.json");
10854
+ RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_2, "run-claude.sh");
10323
10855
  TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
10324
10856
  CHANNEL_2_PORT = 8951;
10325
- SESSION_DIR_3 = join9(homedir9(), ".synkro", "cc_sessions_3");
10326
- PLUGIN_PATH_3 = join9(SESSION_DIR_3, "synkro-channel.ts");
10327
- PLUGIN_PKG_PATH_3 = join9(SESSION_DIR_3, "package.json");
10328
- PLUGIN_SETTINGS_DIR_3 = join9(SESSION_DIR_3, ".claude");
10329
- PLUGIN_SETTINGS_PATH_3 = join9(PLUGIN_SETTINGS_DIR_3, "settings.json");
10330
- PROJECT_MCP_PATH_3 = join9(SESSION_DIR_3, ".mcp.json");
10331
- RUN_SCRIPT_PATH_3 = join9(SESSION_DIR_3, "run-claude.sh");
10857
+ SESSION_DIR_3 = join10(homedir10(), ".synkro", "cc_sessions_3");
10858
+ PLUGIN_PATH_3 = join10(SESSION_DIR_3, "synkro-channel.ts");
10859
+ PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_3, "package.json");
10860
+ PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_3, ".claude");
10861
+ PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
10862
+ PROJECT_MCP_PATH_3 = join10(SESSION_DIR_3, ".mcp.json");
10863
+ RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_3, "run-claude.sh");
10332
10864
  TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
10333
10865
  CHANNEL_3_PORT = 8942;
10334
- SESSION_DIR_4 = join9(homedir9(), ".synkro", "cc_sessions_4");
10335
- PLUGIN_PATH_4 = join9(SESSION_DIR_4, "synkro-channel.ts");
10336
- PLUGIN_PKG_PATH_4 = join9(SESSION_DIR_4, "package.json");
10337
- PLUGIN_SETTINGS_DIR_4 = join9(SESSION_DIR_4, ".claude");
10338
- PLUGIN_SETTINGS_PATH_4 = join9(PLUGIN_SETTINGS_DIR_4, "settings.json");
10339
- PROJECT_MCP_PATH_4 = join9(SESSION_DIR_4, ".mcp.json");
10340
- RUN_SCRIPT_PATH_4 = join9(SESSION_DIR_4, "run-claude.sh");
10866
+ SESSION_DIR_4 = join10(homedir10(), ".synkro", "cc_sessions_4");
10867
+ PLUGIN_PATH_4 = join10(SESSION_DIR_4, "synkro-channel.ts");
10868
+ PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_4, "package.json");
10869
+ PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_4, ".claude");
10870
+ PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
10871
+ PROJECT_MCP_PATH_4 = join10(SESSION_DIR_4, ".mcp.json");
10872
+ RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_4, "run-claude.sh");
10341
10873
  TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
10342
10874
  CHANNEL_4_PORT = 8952;
10343
10875
  RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
@@ -10611,9 +11143,9 @@ var disconnect_exports = {};
10611
11143
  __export(disconnect_exports, {
10612
11144
  disconnectCommand: () => disconnectCommand
10613
11145
  });
10614
- import { existsSync as existsSync12, rmSync, readdirSync as readdirSync4 } from "fs";
10615
- import { homedir as homedir10 } from "os";
10616
- import { join as join10 } from "path";
11146
+ import { existsSync as existsSync13, rmSync, readdirSync as readdirSync4 } from "fs";
11147
+ import { homedir as homedir11 } from "os";
11148
+ import { join as join11 } from "path";
10617
11149
  import { spawnSync as spawnSync5 } from "child_process";
10618
11150
  import { createInterface as createInterface4 } from "readline";
10619
11151
  async function tearDownLocalCC() {
@@ -10687,15 +11219,15 @@ async function disconnectCommand(args2 = []) {
10687
11219
  const cursorMcpRemoved = uninstallCursorMcpConfig();
10688
11220
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
10689
11221
  }
10690
- if (existsSync12(SYNKRO_DIR5)) {
11222
+ if (existsSync13(SYNKRO_DIR5)) {
10691
11223
  if (purge) {
10692
11224
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
10693
11225
  console.log(`\u2713 wiped ${SYNKRO_DIR5} entirely \u2014 including all scan data and backups`);
10694
11226
  } else {
10695
- const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
11227
+ const keep = /* @__PURE__ */ new Set([join11(SYNKRO_DIR5, "pgdata"), join11(SYNKRO_DIR5, "pgdata-backups")]);
10696
11228
  const preserved = [];
10697
11229
  for (const entry of readdirSync4(SYNKRO_DIR5)) {
10698
- const full = join10(SYNKRO_DIR5, entry);
11230
+ const full = join11(SYNKRO_DIR5, entry);
10699
11231
  if (keep.has(full)) {
10700
11232
  preserved.push(entry);
10701
11233
  continue;
@@ -10725,14 +11257,14 @@ var init_disconnect = __esm({
10725
11257
  init_install2();
10726
11258
  init_dockerInstall();
10727
11259
  init_macKeychain();
10728
- SYNKRO_DIR5 = join10(homedir10(), ".synkro");
11260
+ SYNKRO_DIR5 = join11(homedir11(), ".synkro");
10729
11261
  }
10730
11262
  });
10731
11263
 
10732
11264
  // cli/local-cc/turnLog.ts
10733
- import { appendFileSync, existsSync as existsSync13, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync10, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
10734
- import { dirname as dirname6, join as join11 } from "path";
10735
- import { homedir as homedir11 } from "os";
11265
+ import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
11266
+ import { dirname as dirname6, join as join12 } from "path";
11267
+ import { homedir as homedir12 } from "os";
10736
11268
  function truncate(s, max = PREVIEW_MAX) {
10737
11269
  if (s.length <= max) return s;
10738
11270
  return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
@@ -10768,11 +11300,11 @@ function appendTurn(args2) {
10768
11300
  }
10769
11301
  }
10770
11302
  function readRecentTurns(n = 20) {
10771
- if (!existsSync13(TURN_LOG_PATH)) return [];
11303
+ if (!existsSync14(TURN_LOG_PATH)) return [];
10772
11304
  try {
10773
11305
  const size = statSync3(TURN_LOG_PATH).size;
10774
11306
  if (size === 0) return [];
10775
- const text = readFileSync10(TURN_LOG_PATH, "utf-8");
11307
+ const text = readFileSync11(TURN_LOG_PATH, "utf-8");
10776
11308
  const lines = text.split("\n").filter(Boolean);
10777
11309
  const lastN = lines.slice(-n).reverse();
10778
11310
  return lastN.map((line) => {
@@ -10789,7 +11321,7 @@ function readRecentTurns(n = 20) {
10789
11321
  function followTurns(onEntry) {
10790
11322
  try {
10791
11323
  mkdirSync10(dirname6(TURN_LOG_PATH), { recursive: true });
10792
- if (!existsSync13(TURN_LOG_PATH)) {
11324
+ if (!existsSync14(TURN_LOG_PATH)) {
10793
11325
  appendFileSync(TURN_LOG_PATH, "", "utf-8");
10794
11326
  }
10795
11327
  } catch {
@@ -10851,7 +11383,7 @@ var TURN_LOG_PATH, PREVIEW_MAX;
10851
11383
  var init_turnLog = __esm({
10852
11384
  "cli/local-cc/turnLog.ts"() {
10853
11385
  "use strict";
10854
- TURN_LOG_PATH = join11(homedir11(), ".synkro", "cc_sessions", "turns.log");
11386
+ TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
10855
11387
  PREVIEW_MAX = 400;
10856
11388
  }
10857
11389
  });
@@ -11000,9 +11532,9 @@ var init_grade = __esm({
11000
11532
  });
11001
11533
 
11002
11534
  // cli/local-cc/pueue.ts
11003
- import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn } from "child_process";
11004
- import { homedir as homedir12 } from "os";
11005
- import { join as join12 } from "path";
11535
+ import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn as spawn2 } from "child_process";
11536
+ import { homedir as homedir13 } from "os";
11537
+ import { join as join13 } from "path";
11006
11538
  import { connect as connect2 } from "net";
11007
11539
  function pueueAvailable() {
11008
11540
  const r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
@@ -11068,7 +11600,7 @@ function startTask(opts = {}) {
11068
11600
  spawnSync6("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
11069
11601
  existing = findTask(ch);
11070
11602
  }
11071
- const runScript = join12(cwd, "run-claude.sh");
11603
+ const runScript = join13(cwd, "run-claude.sh");
11072
11604
  const args2 = [
11073
11605
  "add",
11074
11606
  "--label",
@@ -11165,7 +11697,7 @@ function assertPueueInstalled() {
11165
11697
  const status = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
11166
11698
  if (status.status !== 0) {
11167
11699
  console.log(" Starting pueued daemon...");
11168
- const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
11700
+ const child = spawn2("pueued", ["-d"], { stdio: "ignore", detached: true });
11169
11701
  child.unref();
11170
11702
  spawnSync6("sleep", ["1"]);
11171
11703
  const retry = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
@@ -11198,12 +11730,12 @@ var init_pueue = __esm({
11198
11730
  "use strict";
11199
11731
  TASK_LABEL = "synkro-local-cc";
11200
11732
  TMUX_SESSION = "synkro-local-cc";
11201
- SESSION_DIR2 = join12(homedir12(), ".synkro", "cc_sessions");
11733
+ SESSION_DIR2 = join13(homedir13(), ".synkro", "cc_sessions");
11202
11734
  TASK_LABEL_2 = "synkro-local-cc-2";
11203
11735
  TMUX_SESSION_2 = "synkro-local-cc-2";
11204
- SESSION_DIR_22 = join12(homedir12(), ".synkro", "cc_sessions_2");
11205
- SESSION_DIR_32 = join12(homedir12(), ".synkro", "cc_sessions_3");
11206
- SESSION_DIR_42 = join12(homedir12(), ".synkro", "cc_sessions_4");
11736
+ SESSION_DIR_22 = join13(homedir13(), ".synkro", "cc_sessions_2");
11737
+ SESSION_DIR_32 = join13(homedir13(), ".synkro", "cc_sessions_3");
11738
+ SESSION_DIR_42 = join13(homedir13(), ".synkro", "cc_sessions_4");
11207
11739
  PueueError = class extends Error {
11208
11740
  constructor(message, cause) {
11209
11741
  super(message);
@@ -11218,13 +11750,13 @@ var init_pueue = __esm({
11218
11750
  });
11219
11751
 
11220
11752
  // cli/local-cc/settings.ts
11221
- import { existsSync as existsSync14, readFileSync as readFileSync11 } from "fs";
11222
- import { homedir as homedir13 } from "os";
11223
- import { join as join13 } from "path";
11753
+ import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
11754
+ import { homedir as homedir14 } from "os";
11755
+ import { join as join14 } from "path";
11224
11756
  function isLocalCCEnabled() {
11225
- if (!existsSync14(CONFIG_PATH3)) return false;
11757
+ if (!existsSync15(CONFIG_PATH3)) return false;
11226
11758
  try {
11227
- const content = readFileSync11(CONFIG_PATH3, "utf-8");
11759
+ const content = readFileSync12(CONFIG_PATH3, "utf-8");
11228
11760
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
11229
11761
  return match?.[1] === "yes";
11230
11762
  } catch {
@@ -11235,7 +11767,7 @@ var CONFIG_PATH3;
11235
11767
  var init_settings = __esm({
11236
11768
  "cli/local-cc/settings.ts"() {
11237
11769
  "use strict";
11238
- CONFIG_PATH3 = join13(homedir13(), ".synkro", "config.env");
11770
+ CONFIG_PATH3 = join14(homedir14(), ".synkro", "config.env");
11239
11771
  }
11240
11772
  });
11241
11773
 
@@ -11245,10 +11777,10 @@ __export(localCc_exports, {
11245
11777
  localCcCommand: () => localCcCommand
11246
11778
  });
11247
11779
  import { spawnSync as spawnSync7 } from "child_process";
11248
- import { homedir as homedir14 } from "os";
11249
- import { join as join14 } from "path";
11780
+ import { homedir as homedir15 } from "os";
11781
+ import { join as join15 } from "path";
11250
11782
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
11251
- import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
11783
+ import { existsSync as existsSync16, readFileSync as readFileSync13, writeFileSync as writeFileSync10 } from "fs";
11252
11784
  function deploymentMode() {
11253
11785
  const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
11254
11786
  if (env === "docker") return "docker";
@@ -11354,15 +11886,15 @@ TROUBLESHOOTING
11354
11886
  `);
11355
11887
  }
11356
11888
  function readGatewayUrl() {
11357
- if (existsSync15(CONFIG_PATH4)) {
11358
- const m = readFileSync12(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11889
+ if (existsSync16(CONFIG_PATH4)) {
11890
+ const m = readFileSync13(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11359
11891
  if (m) return m[1];
11360
11892
  }
11361
11893
  return "https://api.synkro.sh";
11362
11894
  }
11363
11895
  function updateLocalInferenceFlag(enabled) {
11364
- if (!existsSync15(CONFIG_PATH4)) return;
11365
- let content = readFileSync12(CONFIG_PATH4, "utf-8");
11896
+ if (!existsSync16(CONFIG_PATH4)) return;
11897
+ let content = readFileSync13(CONFIG_PATH4, "utf-8");
11366
11898
  const flag = enabled ? "yes" : "no";
11367
11899
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
11368
11900
  content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
@@ -11371,7 +11903,7 @@ function updateLocalInferenceFlag(enabled) {
11371
11903
  SYNKRO_LOCAL_INFERENCE='${flag}'
11372
11904
  `;
11373
11905
  }
11374
- writeFileSync9(CONFIG_PATH4, content, "utf-8");
11906
+ writeFileSync10(CONFIG_PATH4, content, "utf-8");
11375
11907
  }
11376
11908
  async function setServerGradingProvider(provider) {
11377
11909
  await ensureValidToken();
@@ -11801,8 +12333,8 @@ var init_localCc = __esm({
11801
12333
  init_install();
11802
12334
  init_client();
11803
12335
  init_stub();
11804
- SYNKRO_CONFIG_PATH = join14(homedir14(), ".synkro", "config.env");
11805
- CONFIG_PATH4 = join14(homedir14(), ".synkro", "config.env");
12336
+ SYNKRO_CONFIG_PATH = join15(homedir15(), ".synkro", "config.env");
12337
+ CONFIG_PATH4 = join15(homedir15(), ".synkro", "config.env");
11806
12338
  }
11807
12339
  });
11808
12340
 
@@ -11913,13 +12445,13 @@ var config_exports = {};
11913
12445
  __export(config_exports, {
11914
12446
  configCommand: () => configCommand
11915
12447
  });
11916
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync16 } from "fs";
11917
- import { join as join15 } from "path";
11918
- import { homedir as homedir15 } from "os";
12448
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
12449
+ import { join as join16 } from "path";
12450
+ import { homedir as homedir16 } from "os";
11919
12451
  function readConfigEnv() {
11920
- if (!existsSync16(CONFIG_PATH5)) return {};
12452
+ if (!existsSync17(CONFIG_PATH5)) return {};
11921
12453
  const out = {};
11922
- for (const line of readFileSync13(CONFIG_PATH5, "utf-8").split("\n")) {
12454
+ for (const line of readFileSync14(CONFIG_PATH5, "utf-8").split("\n")) {
11923
12455
  const t = line.trim();
11924
12456
  if (!t || t.startsWith("#")) continue;
11925
12457
  const eq = t.indexOf("=");
@@ -11928,11 +12460,11 @@ function readConfigEnv() {
11928
12460
  return out;
11929
12461
  }
11930
12462
  function updateConfigValue(key, value) {
11931
- if (!existsSync16(CONFIG_PATH5)) {
12463
+ if (!existsSync17(CONFIG_PATH5)) {
11932
12464
  console.error("No config found. Run `synkro install` first.");
11933
12465
  process.exit(1);
11934
12466
  }
11935
- const lines = readFileSync13(CONFIG_PATH5, "utf-8").split("\n");
12467
+ const lines = readFileSync14(CONFIG_PATH5, "utf-8").split("\n");
11936
12468
  const pattern = new RegExp(`^${key}=`);
11937
12469
  let found = false;
11938
12470
  const updated = lines.map((line) => {
@@ -11943,7 +12475,7 @@ function updateConfigValue(key, value) {
11943
12475
  return line;
11944
12476
  });
11945
12477
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
11946
- writeFileSync10(CONFIG_PATH5, updated.join("\n"), "utf-8");
12478
+ writeFileSync11(CONFIG_PATH5, updated.join("\n"), "utf-8");
11947
12479
  }
11948
12480
  async function reconcileContainer() {
11949
12481
  const cfg = readConfigEnv();
@@ -12053,20 +12585,20 @@ var init_config = __esm({
12053
12585
  "cli/commands/config.ts"() {
12054
12586
  "use strict";
12055
12587
  init_stub();
12056
- SYNKRO_DIR6 = join15(homedir15(), ".synkro");
12057
- CONFIG_PATH5 = join15(SYNKRO_DIR6, "config.env");
12588
+ SYNKRO_DIR6 = join16(homedir16(), ".synkro");
12589
+ CONFIG_PATH5 = join16(SYNKRO_DIR6, "config.env");
12058
12590
  }
12059
12591
  });
12060
12592
 
12061
12593
  // cli/bootstrap.js
12062
- import { readFileSync as readFileSync14, existsSync as existsSync17 } from "fs";
12594
+ import { readFileSync as readFileSync15, existsSync as existsSync18 } from "fs";
12063
12595
  import { resolve as resolve3 } from "path";
12064
12596
  var envCandidates = [
12065
12597
  resolve3(process.env.HOME ?? "", ".synkro", "config.env")
12066
12598
  ];
12067
12599
  for (const envPath of envCandidates) {
12068
- if (!existsSync17(envPath)) continue;
12069
- const envContent = readFileSync14(envPath, "utf-8");
12600
+ if (!existsSync18(envPath)) continue;
12601
+ const envContent = readFileSync15(envPath, "utf-8");
12070
12602
  for (const line of envContent.split("\n")) {
12071
12603
  const trimmed = line.trim();
12072
12604
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -12081,7 +12613,7 @@ var args = process.argv.slice(2);
12081
12613
  var cmd = args[0] || "";
12082
12614
  var subArgs = args.slice(1);
12083
12615
  function printVersion() {
12084
- console.log("1.6.53");
12616
+ console.log("1.6.54");
12085
12617
  }
12086
12618
  function printHelp2() {
12087
12619
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -12097,6 +12629,7 @@ Commands:
12097
12629
  restart [opts] Safe restart (stop \u2192 start, data preserved)
12098
12630
  update Pull the latest container image and safely restart
12099
12631
  config Show or change grading + storage modes
12632
+ claude-desktop Monitor Claude Desktop conversations (local, macOS)
12100
12633
  version Show version
12101
12634
 
12102
12635
  config:
@@ -12192,6 +12725,11 @@ async function main() {
12192
12725
  await configCommand2(args.slice(1));
12193
12726
  break;
12194
12727
  }
12728
+ case "claude-desktop": {
12729
+ const { runClaudeDesktopTap: runClaudeDesktopTap2 } = await Promise.resolve().then(() => (init_claudeDesktopTap(), claudeDesktopTap_exports));
12730
+ await runClaudeDesktopTap2();
12731
+ break;
12732
+ }
12195
12733
  default: {
12196
12734
  console.error(`Unknown command: ${cmd}`);
12197
12735
  printHelp2();