@synkro-sh/cli 1.6.53 → 1.6.55

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.55")}`
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,9 @@ 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
+ console.log("\n Claude Desktop detected \u2014 to monitor its conversations in your dashboard, run: synkro claude-desktop");
9957
+ }
9435
9958
  }
9436
9959
  function parseTomlValue2(raw) {
9437
9960
  const v = raw.trim();
@@ -9476,8 +9999,8 @@ function writeSynkroFileIfMissing(opts) {
9476
9999
  try {
9477
10000
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9478
10001
  if (!root) return;
9479
- if (root === homedir8()) return;
9480
- const fp = join8(root, "synkro.toml");
10002
+ if (root === homedir9()) return;
10003
+ const fp = join9(root, "synkro.toml");
9481
10004
  let hasFile = false;
9482
10005
  try {
9483
10006
  hasFile = statSync2(fp).isFile();
@@ -9516,7 +10039,7 @@ function writeSynkroFileIfMissing(opts) {
9516
10039
  "cve = true",
9517
10040
  ""
9518
10041
  ].join("\n");
9519
- writeFileSync7(fp, toml, "utf-8");
10042
+ writeFileSync8(fp, toml, "utf-8");
9520
10043
  console.log(` synkro.toml: wrote ${fp} (pool=${pool}, mode=${mode})`);
9521
10044
  } catch {
9522
10045
  }
@@ -9525,9 +10048,9 @@ function readFullSynkroFile() {
9525
10048
  try {
9526
10049
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9527
10050
  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"));
10051
+ const fp = join9(root, "synkro.toml");
10052
+ if (!existsSync11(fp)) return null;
10053
+ const parsed = parseSynkroToml2(readFileSync9(fp, "utf-8"));
9531
10054
  const valid = ["claude-code", "cursor"];
9532
10055
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
9533
10056
  const resolved = resolveGraderPool(parsed);
@@ -9563,7 +10086,7 @@ function reconcileHarness() {
9563
10086
  console.log(`synkro.toml: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
9564
10087
  const scripts = writeHookScripts(resolvePersistedHookMode());
9565
10088
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9566
- const ccSettings = join8(homedir8(), ".claude", "settings.json");
10089
+ const ccSettings = join9(homedir9(), ".claude", "settings.json");
9567
10090
  if (wantCC) {
9568
10091
  installCCHooks(ccSettings, {
9569
10092
  bashJudgeScriptPath: scripts.bashScript,
@@ -9582,7 +10105,7 @@ function reconcileHarness() {
9582
10105
  });
9583
10106
  console.log(" \u2713 Claude Code hooks registered");
9584
10107
  try {
9585
- const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
10108
+ const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9586
10109
  if (mcpJwt) {
9587
10110
  installMcpConfig({ gatewayUrl: "", bearerToken: mcpJwt, local: true });
9588
10111
  console.log(" \u2713 Claude Code MCP registered");
@@ -9593,7 +10116,7 @@ function reconcileHarness() {
9593
10116
  if (uninstallCCHooks(ccSettings)) console.log(" \u2717 Claude Code hooks removed");
9594
10117
  if (uninstallMcpConfig()) console.log(" \u2717 Claude Code MCP removed");
9595
10118
  }
9596
- const cursorHooks = join8(homedir8(), ".cursor", "hooks.json");
10119
+ const cursorHooks = join9(homedir9(), ".cursor", "hooks.json");
9597
10120
  if (wantCursor) {
9598
10121
  installCursorHooks(cursorHooks, {
9599
10122
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
@@ -9646,7 +10169,7 @@ async function syncSkillFiles() {
9646
10169
  if (resolved.length === 0) return;
9647
10170
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
9648
10171
  const tasks = resolved.map((fp) => {
9649
- const content = readFileSync8(fp, "utf-8");
10172
+ const content = readFileSync9(fp, "utf-8");
9650
10173
  const source = `skill:${fp.split("/").pop()}`;
9651
10174
  if (!content.trim()) {
9652
10175
  console.log(` \u2298 skill ${source}: empty file, skipped`);
@@ -9692,17 +10215,17 @@ function detectGitRepo2() {
9692
10215
  function getClaudeProjectsFolder() {
9693
10216
  const cwd = process.cwd();
9694
10217
  const sanitized = "-" + cwd.replace(/\//g, "-");
9695
- const projectsDir = join8(homedir8(), ".claude", "projects", sanitized);
9696
- return existsSync10(projectsDir) ? projectsDir : null;
10218
+ const projectsDir = join9(homedir9(), ".claude", "projects", sanitized);
10219
+ return existsSync11(projectsDir) ? projectsDir : null;
9697
10220
  }
9698
10221
  function extractSessionInsights(projectsDir) {
9699
10222
  const insights = [];
9700
10223
  const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
9701
10224
  for (const file of files) {
9702
10225
  const sessionId = file.replace(".jsonl", "");
9703
- const filePath = join8(projectsDir, file);
10226
+ const filePath = join9(projectsDir, file);
9704
10227
  try {
9705
- const content = readFileSync8(filePath, "utf-8");
10228
+ const content = readFileSync9(filePath, "utf-8");
9706
10229
  const lines = content.split("\n").filter(Boolean);
9707
10230
  for (let i = 0; i < lines.length; i++) {
9708
10231
  try {
@@ -9781,14 +10304,14 @@ function cursorProjectSlug(workspaceRoot) {
9781
10304
  return workspaceRoot.replace(/^[/]+/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9782
10305
  }
9783
10306
  function getCursorTranscriptsDir() {
9784
- const dir = join8(homedir8(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
9785
- return existsSync10(dir) ? dir : null;
10307
+ const dir = join9(homedir9(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
10308
+ return existsSync11(dir) ? dir : null;
9786
10309
  }
9787
10310
  function isSafeConvId(id) {
9788
10311
  return /^[A-Za-z0-9_-]+$/.test(id);
9789
10312
  }
9790
10313
  function parseCursorTranscriptFile(filePath) {
9791
- const content = readFileSync8(filePath, "utf-8");
10314
+ const content = readFileSync9(filePath, "utf-8");
9792
10315
  const lines = content.split("\n").filter(Boolean);
9793
10316
  const messages = [];
9794
10317
  for (let i = 0; i < lines.length; i++) {
@@ -9820,8 +10343,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9820
10343
  for (let i = 0; i < convDirs.length; i++) {
9821
10344
  const convId = convDirs[i];
9822
10345
  if (!isSafeConvId(convId)) continue;
9823
- const filePath = join8(dir, convId, `${convId}.jsonl`);
9824
- if (!existsSync10(filePath)) continue;
10346
+ const filePath = join9(dir, convId, `${convId}.jsonl`);
10347
+ if (!existsSync11(filePath)) continue;
9825
10348
  try {
9826
10349
  const all = parseCursorTranscriptFile(filePath);
9827
10350
  const messages = all.length > 500 ? all.slice(-500) : all;
@@ -9843,8 +10366,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9843
10366
  process.stdout.write(`\r Progress: ${i + 1}/${convDirs.length} sessions (${totalMessages} messages embedded) `);
9844
10367
  }
9845
10368
  try {
9846
- const lc = readFileSync8(filePath, "utf-8").split("\n").filter(Boolean).length;
9847
- writeFileSync7(join8(OFFSETS_DIR, convId), String(lc), "utf-8");
10369
+ const lc = readFileSync9(filePath, "utf-8").split("\n").filter(Boolean).length;
10370
+ writeFileSync8(join9(OFFSETS_DIR, convId), String(lc), "utf-8");
9848
10371
  } catch {
9849
10372
  }
9850
10373
  }
@@ -9852,7 +10375,7 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9852
10375
  return { sessions: totalSessions, messages: totalMessages };
9853
10376
  }
9854
10377
  function parseTranscriptFile(filePath) {
9855
- const content = readFileSync8(filePath, "utf-8");
10378
+ const content = readFileSync9(filePath, "utf-8");
9856
10379
  const lines = content.split("\n").filter(Boolean);
9857
10380
  const messages = [];
9858
10381
  for (let i = 0; i < lines.length; i++) {
@@ -9900,7 +10423,7 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9900
10423
  for (let i = 0; i < files.length; i++) {
9901
10424
  const file = files[i];
9902
10425
  const sessionId = file.replace(".jsonl", "");
9903
- const filePath = join8(projectsDir, file);
10426
+ const filePath = join9(projectsDir, file);
9904
10427
  try {
9905
10428
  const allMessages = parseTranscriptFile(filePath);
9906
10429
  const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
@@ -9922,9 +10445,9 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9922
10445
  process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
9923
10446
  }
9924
10447
  try {
9925
- const content = readFileSync8(join8(projectsDir, file), "utf-8");
10448
+ const content = readFileSync9(join9(projectsDir, file), "utf-8");
9926
10449
  const lineCount = content.split("\n").filter(Boolean).length;
9927
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10450
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9928
10451
  } catch {
9929
10452
  }
9930
10453
  }
@@ -9945,7 +10468,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9945
10468
  const sessions = [];
9946
10469
  for (const file of batch) {
9947
10470
  const sessionId = file.replace(".jsonl", "");
9948
- const filePath = join8(projectsDir, file);
10471
+ const filePath = join9(projectsDir, file);
9949
10472
  try {
9950
10473
  const allMessages = parseTranscriptFile(filePath);
9951
10474
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -9974,11 +10497,11 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9974
10497
  }
9975
10498
  for (const file of batch) {
9976
10499
  const sessionId = file.replace(".jsonl", "");
9977
- const filePath = join8(projectsDir, file);
10500
+ const filePath = join9(projectsDir, file);
9978
10501
  try {
9979
- const content = readFileSync8(filePath, "utf-8");
10502
+ const content = readFileSync9(filePath, "utf-8");
9980
10503
  const lineCount = content.split("\n").filter(Boolean).length;
9981
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10504
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9982
10505
  } catch {
9983
10506
  }
9984
10507
  }
@@ -10001,11 +10524,12 @@ var init_install = __esm({
10001
10524
  init_repoConnect();
10002
10525
  init_projects();
10003
10526
  init_promptFetcher();
10527
+ init_claudeDesktopTap();
10004
10528
  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");
10529
+ SYNKRO_DIR4 = join9(homedir9(), ".synkro");
10530
+ HOOKS_DIR = join9(SYNKRO_DIR4, "hooks");
10531
+ BIN_DIR = join9(SYNKRO_DIR4, "bin");
10532
+ CONFIG_PATH2 = join9(SYNKRO_DIR4, "config.env");
10009
10533
  MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
10010
10534
  import { readFileSync } from 'node:fs';
10011
10535
  import { homedir } from 'node:os';
@@ -10116,21 +10640,21 @@ rl.on('line', async (line) => {
10116
10640
  }
10117
10641
  });
10118
10642
  `;
10119
- OFFSETS_DIR = join8(SYNKRO_DIR4, ".transcript-offsets");
10643
+ OFFSETS_DIR = join9(SYNKRO_DIR4, ".transcript-offsets");
10120
10644
  }
10121
10645
  });
10122
10646
 
10123
10647
  // 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";
10648
+ 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";
10649
+ import { join as join10 } from "path";
10650
+ import { homedir as homedir10 } from "os";
10127
10651
  import { spawnSync as spawnSync4 } from "child_process";
10128
10652
  function writePluginFiles() {
10129
10653
  for (const c of CHANNELS) {
10130
10654
  mkdirSync9(c.sessionDir, { recursive: true });
10131
10655
  mkdirSync9(c.pluginSettingsDir, { recursive: true });
10132
- writeFileSync8(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10133
- writeFileSync8(
10656
+ writeFileSync9(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10657
+ writeFileSync9(
10134
10658
  c.pluginSettingsPath,
10135
10659
  JSON.stringify({
10136
10660
  fastMode: true,
@@ -10138,7 +10662,7 @@ function writePluginFiles() {
10138
10662
  }, null, 2) + "\n",
10139
10663
  "utf-8"
10140
10664
  );
10141
- writeFileSync8(c.runScriptPath, c.runScriptSource, "utf-8");
10665
+ writeFileSync9(c.runScriptPath, c.runScriptSource, "utf-8");
10142
10666
  chmodSync3(c.runScriptPath, 493);
10143
10667
  }
10144
10668
  }
@@ -10157,10 +10681,10 @@ function runBunInstall() {
10157
10681
  }
10158
10682
  }
10159
10683
  function safelyMutateClaudeJson(mutator) {
10160
- if (!existsSync11(CLAUDE_JSON_PATH)) {
10684
+ if (!existsSync12(CLAUDE_JSON_PATH)) {
10161
10685
  return;
10162
10686
  }
10163
- const originalText = readFileSync9(CLAUDE_JSON_PATH, "utf-8");
10687
+ const originalText = readFileSync10(CLAUDE_JSON_PATH, "utf-8");
10164
10688
  let parsed;
10165
10689
  try {
10166
10690
  parsed = JSON.parse(originalText);
@@ -10192,7 +10716,7 @@ function safelyMutateClaudeJson(mutator) {
10192
10716
  copyFileSync2(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
10193
10717
  const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
10194
10718
  try {
10195
- writeFileSync8(tmpPath, newText, "utf-8");
10719
+ writeFileSync9(tmpPath, newText, "utf-8");
10196
10720
  const fd = openSync(tmpPath, "r");
10197
10721
  try {
10198
10722
  fsyncSync(fd);
@@ -10225,7 +10749,7 @@ function writeProjectMcpJson() {
10225
10749
  }
10226
10750
  }
10227
10751
  };
10228
- writeFileSync8(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10752
+ writeFileSync9(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10229
10753
  }
10230
10754
  }
10231
10755
  function patchClaudeJson() {
@@ -10302,42 +10826,42 @@ var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_S
10302
10826
  var init_install2 = __esm({
10303
10827
  "cli/local-cc/install.ts"() {
10304
10828
  "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");
10829
+ CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
10830
+ SESSION_DIR = join10(homedir10(), ".synkro", "cc_sessions");
10831
+ PLUGIN_PATH = join10(SESSION_DIR, "synkro-channel.ts");
10832
+ PLUGIN_PKG_PATH = join10(SESSION_DIR, "package.json");
10833
+ PLUGIN_SETTINGS_DIR = join10(SESSION_DIR, ".claude");
10834
+ PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
10835
+ PROJECT_MCP_PATH = join10(SESSION_DIR, ".mcp.json");
10836
+ CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
10837
+ RUN_SCRIPT_PATH = join10(SESSION_DIR, "run-claude.sh");
10314
10838
  TMUX_SESSION_NAME = "synkro-local-cc";
10315
10839
  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");
10840
+ SESSION_DIR_2 = join10(homedir10(), ".synkro", "cc_sessions_2");
10841
+ PLUGIN_PATH_2 = join10(SESSION_DIR_2, "synkro-channel.ts");
10842
+ PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_2, "package.json");
10843
+ PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_2, ".claude");
10844
+ PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
10845
+ PROJECT_MCP_PATH_2 = join10(SESSION_DIR_2, ".mcp.json");
10846
+ RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_2, "run-claude.sh");
10323
10847
  TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
10324
10848
  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");
10849
+ SESSION_DIR_3 = join10(homedir10(), ".synkro", "cc_sessions_3");
10850
+ PLUGIN_PATH_3 = join10(SESSION_DIR_3, "synkro-channel.ts");
10851
+ PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_3, "package.json");
10852
+ PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_3, ".claude");
10853
+ PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
10854
+ PROJECT_MCP_PATH_3 = join10(SESSION_DIR_3, ".mcp.json");
10855
+ RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_3, "run-claude.sh");
10332
10856
  TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
10333
10857
  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");
10858
+ SESSION_DIR_4 = join10(homedir10(), ".synkro", "cc_sessions_4");
10859
+ PLUGIN_PATH_4 = join10(SESSION_DIR_4, "synkro-channel.ts");
10860
+ PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_4, "package.json");
10861
+ PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_4, ".claude");
10862
+ PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
10863
+ PROJECT_MCP_PATH_4 = join10(SESSION_DIR_4, ".mcp.json");
10864
+ RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_4, "run-claude.sh");
10341
10865
  TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
10342
10866
  CHANNEL_4_PORT = 8952;
10343
10867
  RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
@@ -10611,9 +11135,9 @@ var disconnect_exports = {};
10611
11135
  __export(disconnect_exports, {
10612
11136
  disconnectCommand: () => disconnectCommand
10613
11137
  });
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";
11138
+ import { existsSync as existsSync13, rmSync, readdirSync as readdirSync4 } from "fs";
11139
+ import { homedir as homedir11 } from "os";
11140
+ import { join as join11 } from "path";
10617
11141
  import { spawnSync as spawnSync5 } from "child_process";
10618
11142
  import { createInterface as createInterface4 } from "readline";
10619
11143
  async function tearDownLocalCC() {
@@ -10687,15 +11211,15 @@ async function disconnectCommand(args2 = []) {
10687
11211
  const cursorMcpRemoved = uninstallCursorMcpConfig();
10688
11212
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
10689
11213
  }
10690
- if (existsSync12(SYNKRO_DIR5)) {
11214
+ if (existsSync13(SYNKRO_DIR5)) {
10691
11215
  if (purge) {
10692
11216
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
10693
11217
  console.log(`\u2713 wiped ${SYNKRO_DIR5} entirely \u2014 including all scan data and backups`);
10694
11218
  } else {
10695
- const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
11219
+ const keep = /* @__PURE__ */ new Set([join11(SYNKRO_DIR5, "pgdata"), join11(SYNKRO_DIR5, "pgdata-backups")]);
10696
11220
  const preserved = [];
10697
11221
  for (const entry of readdirSync4(SYNKRO_DIR5)) {
10698
- const full = join10(SYNKRO_DIR5, entry);
11222
+ const full = join11(SYNKRO_DIR5, entry);
10699
11223
  if (keep.has(full)) {
10700
11224
  preserved.push(entry);
10701
11225
  continue;
@@ -10725,14 +11249,14 @@ var init_disconnect = __esm({
10725
11249
  init_install2();
10726
11250
  init_dockerInstall();
10727
11251
  init_macKeychain();
10728
- SYNKRO_DIR5 = join10(homedir10(), ".synkro");
11252
+ SYNKRO_DIR5 = join11(homedir11(), ".synkro");
10729
11253
  }
10730
11254
  });
10731
11255
 
10732
11256
  // 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";
11257
+ 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";
11258
+ import { dirname as dirname6, join as join12 } from "path";
11259
+ import { homedir as homedir12 } from "os";
10736
11260
  function truncate(s, max = PREVIEW_MAX) {
10737
11261
  if (s.length <= max) return s;
10738
11262
  return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
@@ -10768,11 +11292,11 @@ function appendTurn(args2) {
10768
11292
  }
10769
11293
  }
10770
11294
  function readRecentTurns(n = 20) {
10771
- if (!existsSync13(TURN_LOG_PATH)) return [];
11295
+ if (!existsSync14(TURN_LOG_PATH)) return [];
10772
11296
  try {
10773
11297
  const size = statSync3(TURN_LOG_PATH).size;
10774
11298
  if (size === 0) return [];
10775
- const text = readFileSync10(TURN_LOG_PATH, "utf-8");
11299
+ const text = readFileSync11(TURN_LOG_PATH, "utf-8");
10776
11300
  const lines = text.split("\n").filter(Boolean);
10777
11301
  const lastN = lines.slice(-n).reverse();
10778
11302
  return lastN.map((line) => {
@@ -10789,7 +11313,7 @@ function readRecentTurns(n = 20) {
10789
11313
  function followTurns(onEntry) {
10790
11314
  try {
10791
11315
  mkdirSync10(dirname6(TURN_LOG_PATH), { recursive: true });
10792
- if (!existsSync13(TURN_LOG_PATH)) {
11316
+ if (!existsSync14(TURN_LOG_PATH)) {
10793
11317
  appendFileSync(TURN_LOG_PATH, "", "utf-8");
10794
11318
  }
10795
11319
  } catch {
@@ -10851,7 +11375,7 @@ var TURN_LOG_PATH, PREVIEW_MAX;
10851
11375
  var init_turnLog = __esm({
10852
11376
  "cli/local-cc/turnLog.ts"() {
10853
11377
  "use strict";
10854
- TURN_LOG_PATH = join11(homedir11(), ".synkro", "cc_sessions", "turns.log");
11378
+ TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
10855
11379
  PREVIEW_MAX = 400;
10856
11380
  }
10857
11381
  });
@@ -11000,9 +11524,9 @@ var init_grade = __esm({
11000
11524
  });
11001
11525
 
11002
11526
  // 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";
11527
+ import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn as spawn2 } from "child_process";
11528
+ import { homedir as homedir13 } from "os";
11529
+ import { join as join13 } from "path";
11006
11530
  import { connect as connect2 } from "net";
11007
11531
  function pueueAvailable() {
11008
11532
  const r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
@@ -11068,7 +11592,7 @@ function startTask(opts = {}) {
11068
11592
  spawnSync6("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
11069
11593
  existing = findTask(ch);
11070
11594
  }
11071
- const runScript = join12(cwd, "run-claude.sh");
11595
+ const runScript = join13(cwd, "run-claude.sh");
11072
11596
  const args2 = [
11073
11597
  "add",
11074
11598
  "--label",
@@ -11165,7 +11689,7 @@ function assertPueueInstalled() {
11165
11689
  const status = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
11166
11690
  if (status.status !== 0) {
11167
11691
  console.log(" Starting pueued daemon...");
11168
- const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
11692
+ const child = spawn2("pueued", ["-d"], { stdio: "ignore", detached: true });
11169
11693
  child.unref();
11170
11694
  spawnSync6("sleep", ["1"]);
11171
11695
  const retry = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
@@ -11198,12 +11722,12 @@ var init_pueue = __esm({
11198
11722
  "use strict";
11199
11723
  TASK_LABEL = "synkro-local-cc";
11200
11724
  TMUX_SESSION = "synkro-local-cc";
11201
- SESSION_DIR2 = join12(homedir12(), ".synkro", "cc_sessions");
11725
+ SESSION_DIR2 = join13(homedir13(), ".synkro", "cc_sessions");
11202
11726
  TASK_LABEL_2 = "synkro-local-cc-2";
11203
11727
  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");
11728
+ SESSION_DIR_22 = join13(homedir13(), ".synkro", "cc_sessions_2");
11729
+ SESSION_DIR_32 = join13(homedir13(), ".synkro", "cc_sessions_3");
11730
+ SESSION_DIR_42 = join13(homedir13(), ".synkro", "cc_sessions_4");
11207
11731
  PueueError = class extends Error {
11208
11732
  constructor(message, cause) {
11209
11733
  super(message);
@@ -11218,13 +11742,13 @@ var init_pueue = __esm({
11218
11742
  });
11219
11743
 
11220
11744
  // 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";
11745
+ import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
11746
+ import { homedir as homedir14 } from "os";
11747
+ import { join as join14 } from "path";
11224
11748
  function isLocalCCEnabled() {
11225
- if (!existsSync14(CONFIG_PATH3)) return false;
11749
+ if (!existsSync15(CONFIG_PATH3)) return false;
11226
11750
  try {
11227
- const content = readFileSync11(CONFIG_PATH3, "utf-8");
11751
+ const content = readFileSync12(CONFIG_PATH3, "utf-8");
11228
11752
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
11229
11753
  return match?.[1] === "yes";
11230
11754
  } catch {
@@ -11235,7 +11759,7 @@ var CONFIG_PATH3;
11235
11759
  var init_settings = __esm({
11236
11760
  "cli/local-cc/settings.ts"() {
11237
11761
  "use strict";
11238
- CONFIG_PATH3 = join13(homedir13(), ".synkro", "config.env");
11762
+ CONFIG_PATH3 = join14(homedir14(), ".synkro", "config.env");
11239
11763
  }
11240
11764
  });
11241
11765
 
@@ -11245,10 +11769,10 @@ __export(localCc_exports, {
11245
11769
  localCcCommand: () => localCcCommand
11246
11770
  });
11247
11771
  import { spawnSync as spawnSync7 } from "child_process";
11248
- import { homedir as homedir14 } from "os";
11249
- import { join as join14 } from "path";
11772
+ import { homedir as homedir15 } from "os";
11773
+ import { join as join15 } from "path";
11250
11774
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
11251
- import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
11775
+ import { existsSync as existsSync16, readFileSync as readFileSync13, writeFileSync as writeFileSync10 } from "fs";
11252
11776
  function deploymentMode() {
11253
11777
  const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
11254
11778
  if (env === "docker") return "docker";
@@ -11354,15 +11878,15 @@ TROUBLESHOOTING
11354
11878
  `);
11355
11879
  }
11356
11880
  function readGatewayUrl() {
11357
- if (existsSync15(CONFIG_PATH4)) {
11358
- const m = readFileSync12(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11881
+ if (existsSync16(CONFIG_PATH4)) {
11882
+ const m = readFileSync13(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11359
11883
  if (m) return m[1];
11360
11884
  }
11361
11885
  return "https://api.synkro.sh";
11362
11886
  }
11363
11887
  function updateLocalInferenceFlag(enabled) {
11364
- if (!existsSync15(CONFIG_PATH4)) return;
11365
- let content = readFileSync12(CONFIG_PATH4, "utf-8");
11888
+ if (!existsSync16(CONFIG_PATH4)) return;
11889
+ let content = readFileSync13(CONFIG_PATH4, "utf-8");
11366
11890
  const flag = enabled ? "yes" : "no";
11367
11891
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
11368
11892
  content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
@@ -11371,7 +11895,7 @@ function updateLocalInferenceFlag(enabled) {
11371
11895
  SYNKRO_LOCAL_INFERENCE='${flag}'
11372
11896
  `;
11373
11897
  }
11374
- writeFileSync9(CONFIG_PATH4, content, "utf-8");
11898
+ writeFileSync10(CONFIG_PATH4, content, "utf-8");
11375
11899
  }
11376
11900
  async function setServerGradingProvider(provider) {
11377
11901
  await ensureValidToken();
@@ -11801,8 +12325,8 @@ var init_localCc = __esm({
11801
12325
  init_install();
11802
12326
  init_client();
11803
12327
  init_stub();
11804
- SYNKRO_CONFIG_PATH = join14(homedir14(), ".synkro", "config.env");
11805
- CONFIG_PATH4 = join14(homedir14(), ".synkro", "config.env");
12328
+ SYNKRO_CONFIG_PATH = join15(homedir15(), ".synkro", "config.env");
12329
+ CONFIG_PATH4 = join15(homedir15(), ".synkro", "config.env");
11806
12330
  }
11807
12331
  });
11808
12332
 
@@ -11913,13 +12437,13 @@ var config_exports = {};
11913
12437
  __export(config_exports, {
11914
12438
  configCommand: () => configCommand
11915
12439
  });
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";
12440
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
12441
+ import { join as join16 } from "path";
12442
+ import { homedir as homedir16 } from "os";
11919
12443
  function readConfigEnv() {
11920
- if (!existsSync16(CONFIG_PATH5)) return {};
12444
+ if (!existsSync17(CONFIG_PATH5)) return {};
11921
12445
  const out = {};
11922
- for (const line of readFileSync13(CONFIG_PATH5, "utf-8").split("\n")) {
12446
+ for (const line of readFileSync14(CONFIG_PATH5, "utf-8").split("\n")) {
11923
12447
  const t = line.trim();
11924
12448
  if (!t || t.startsWith("#")) continue;
11925
12449
  const eq = t.indexOf("=");
@@ -11928,11 +12452,11 @@ function readConfigEnv() {
11928
12452
  return out;
11929
12453
  }
11930
12454
  function updateConfigValue(key, value) {
11931
- if (!existsSync16(CONFIG_PATH5)) {
12455
+ if (!existsSync17(CONFIG_PATH5)) {
11932
12456
  console.error("No config found. Run `synkro install` first.");
11933
12457
  process.exit(1);
11934
12458
  }
11935
- const lines = readFileSync13(CONFIG_PATH5, "utf-8").split("\n");
12459
+ const lines = readFileSync14(CONFIG_PATH5, "utf-8").split("\n");
11936
12460
  const pattern = new RegExp(`^${key}=`);
11937
12461
  let found = false;
11938
12462
  const updated = lines.map((line) => {
@@ -11943,7 +12467,7 @@ function updateConfigValue(key, value) {
11943
12467
  return line;
11944
12468
  });
11945
12469
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
11946
- writeFileSync10(CONFIG_PATH5, updated.join("\n"), "utf-8");
12470
+ writeFileSync11(CONFIG_PATH5, updated.join("\n"), "utf-8");
11947
12471
  }
11948
12472
  async function reconcileContainer() {
11949
12473
  const cfg = readConfigEnv();
@@ -12053,20 +12577,20 @@ var init_config = __esm({
12053
12577
  "cli/commands/config.ts"() {
12054
12578
  "use strict";
12055
12579
  init_stub();
12056
- SYNKRO_DIR6 = join15(homedir15(), ".synkro");
12057
- CONFIG_PATH5 = join15(SYNKRO_DIR6, "config.env");
12580
+ SYNKRO_DIR6 = join16(homedir16(), ".synkro");
12581
+ CONFIG_PATH5 = join16(SYNKRO_DIR6, "config.env");
12058
12582
  }
12059
12583
  });
12060
12584
 
12061
12585
  // cli/bootstrap.js
12062
- import { readFileSync as readFileSync14, existsSync as existsSync17 } from "fs";
12586
+ import { readFileSync as readFileSync15, existsSync as existsSync18 } from "fs";
12063
12587
  import { resolve as resolve3 } from "path";
12064
12588
  var envCandidates = [
12065
12589
  resolve3(process.env.HOME ?? "", ".synkro", "config.env")
12066
12590
  ];
12067
12591
  for (const envPath of envCandidates) {
12068
- if (!existsSync17(envPath)) continue;
12069
- const envContent = readFileSync14(envPath, "utf-8");
12592
+ if (!existsSync18(envPath)) continue;
12593
+ const envContent = readFileSync15(envPath, "utf-8");
12070
12594
  for (const line of envContent.split("\n")) {
12071
12595
  const trimmed = line.trim();
12072
12596
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -12081,7 +12605,7 @@ var args = process.argv.slice(2);
12081
12605
  var cmd = args[0] || "";
12082
12606
  var subArgs = args.slice(1);
12083
12607
  function printVersion() {
12084
- console.log("1.6.53");
12608
+ console.log("1.6.55");
12085
12609
  }
12086
12610
  function printHelp2() {
12087
12611
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -12097,6 +12621,7 @@ Commands:
12097
12621
  restart [opts] Safe restart (stop \u2192 start, data preserved)
12098
12622
  update Pull the latest container image and safely restart
12099
12623
  config Show or change grading + storage modes
12624
+ claude-desktop Monitor Claude Desktop conversations (local, macOS)
12100
12625
  version Show version
12101
12626
 
12102
12627
  config:
@@ -12192,6 +12717,11 @@ async function main() {
12192
12717
  await configCommand2(args.slice(1));
12193
12718
  break;
12194
12719
  }
12720
+ case "claude-desktop": {
12721
+ const { runClaudeDesktopTap: runClaudeDesktopTap2 } = await Promise.resolve().then(() => (init_claudeDesktopTap(), claudeDesktopTap_exports));
12722
+ await runClaudeDesktopTap2();
12723
+ break;
12724
+ }
12195
12725
  default: {
12196
12726
  console.error(`Unknown command: ${cmd}`);
12197
12727
  printHelp2();