@synkro-sh/cli 1.6.57 → 1.6.59

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
@@ -509,6 +509,8 @@ var init_cursorHookConfig = __esm({
509
509
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, renameSync as renameSync3, mkdirSync as mkdirSync3 } from "fs";
510
510
  import { homedir as homedir3 } from "os";
511
511
  import { dirname as dirname3, join as join2 } from "path";
512
+ import { spawnSync } from "child_process";
513
+ import { randomBytes } from "crypto";
512
514
  function readClaudeJson() {
513
515
  if (!existsSync3(CC_CONFIG_PATH)) return {};
514
516
  try {
@@ -630,7 +632,69 @@ function uninstallCursorMcpConfig() {
630
632
  writeCursorMcpJsonAtomic(config);
631
633
  return true;
632
634
  }
633
- var SYNKRO_MARKER3, SYNKRO_SERVER_NAME, CC_CONFIG_PATH, CURSOR_MCP_PATH;
635
+ function resolveBunBin() {
636
+ const r = spawnSync("which", ["bun"], { encoding: "utf-8", timeout: 5e3 });
637
+ const resolved = (r.stdout || "").split("\n")[0].trim();
638
+ if (resolved) return resolved;
639
+ for (const p of ["/opt/homebrew/bin/bun", "/usr/local/bin/bun", join2(homedir3(), ".bun", "bin", "bun")]) {
640
+ if (existsSync3(p)) return p;
641
+ }
642
+ return "bun";
643
+ }
644
+ function readDesktopJson() {
645
+ if (!existsSync3(CLAUDE_DESKTOP_CONFIG_PATH)) return {};
646
+ try {
647
+ return JSON.parse(readFileSync3(CLAUDE_DESKTOP_CONFIG_PATH, "utf-8"));
648
+ } catch (err) {
649
+ throw new Error(`Failed to parse ${CLAUDE_DESKTOP_CONFIG_PATH}: ${err.message}`);
650
+ }
651
+ }
652
+ function writeDesktopJsonAtomic(config) {
653
+ mkdirSync3(dirname3(CLAUDE_DESKTOP_CONFIG_PATH), { recursive: true });
654
+ const tmpPath = `${CLAUDE_DESKTOP_CONFIG_PATH}.synkro.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
655
+ writeFileSync3(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
656
+ renameSync3(tmpPath, CLAUDE_DESKTOP_CONFIG_PATH);
657
+ }
658
+ function installClaudeDesktopMcpConfig() {
659
+ if (!existsSync3(MCP_STDIO_PROXY_PATH)) {
660
+ return {
661
+ ok: false,
662
+ path: CLAUDE_DESKTOP_CONFIG_PATH,
663
+ proxy: MCP_STDIO_PROXY_PATH,
664
+ reason: "stdio proxy not found \u2014 run `synkro install` first"
665
+ };
666
+ }
667
+ const config = readDesktopJson();
668
+ config.mcpServers = config.mcpServers ?? {};
669
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
670
+ if (entry?.[SYNKRO_MARKER3] === true) delete config.mcpServers[name];
671
+ }
672
+ config.mcpServers[SYNKRO_SERVER_NAME] = {
673
+ command: resolveBunBin(),
674
+ args: ["run", MCP_STDIO_PROXY_PATH],
675
+ env: { SYNKRO_MCP_PORT: process.env.SYNKRO_MCP_PORT || "18931" },
676
+ [SYNKRO_MARKER3]: true
677
+ };
678
+ writeDesktopJsonAtomic(config);
679
+ return { ok: true, path: CLAUDE_DESKTOP_CONFIG_PATH, proxy: MCP_STDIO_PROXY_PATH };
680
+ }
681
+ function uninstallClaudeDesktopMcpConfig() {
682
+ if (!existsSync3(CLAUDE_DESKTOP_CONFIG_PATH)) return false;
683
+ const config = readDesktopJson();
684
+ if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) return false;
685
+ let removed = false;
686
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
687
+ if (entry?.[SYNKRO_MARKER3] === true) {
688
+ delete config.mcpServers[name];
689
+ removed = true;
690
+ }
691
+ }
692
+ if (!removed) return false;
693
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
694
+ writeDesktopJsonAtomic(config);
695
+ return true;
696
+ }
697
+ var SYNKRO_MARKER3, SYNKRO_SERVER_NAME, CC_CONFIG_PATH, CURSOR_MCP_PATH, CLAUDE_DESKTOP_CONFIG_PATH, MCP_STDIO_PROXY_PATH;
634
698
  var init_mcpConfig = __esm({
635
699
  "cli/installer/mcpConfig.ts"() {
636
700
  "use strict";
@@ -638,6 +702,14 @@ var init_mcpConfig = __esm({
638
702
  SYNKRO_SERVER_NAME = "synkro-guardrails";
639
703
  CC_CONFIG_PATH = join2(homedir3(), ".claude.json");
640
704
  CURSOR_MCP_PATH = join2(homedir3(), ".cursor", "mcp.json");
705
+ CLAUDE_DESKTOP_CONFIG_PATH = join2(
706
+ homedir3(),
707
+ "Library",
708
+ "Application Support",
709
+ "Claude",
710
+ "claude_desktop_config.json"
711
+ );
712
+ MCP_STDIO_PROXY_PATH = join2(homedir3(), ".synkro", "hooks", "mcp-stdio-proxy.ts");
641
713
  }
642
714
  });
643
715
 
@@ -1595,6 +1667,28 @@ async function cloudGrade(surface: string, prompt: string, jwt: string, timeoutM
1595
1667
  return String(data.verdict || '');
1596
1668
  }
1597
1669
 
1670
+ // Cloud-container grading \u2014 the org's grader runs on Cloudflare Containers. Same
1671
+ // request/response shape as channelGrade (the host-host Worker forwards verbatim
1672
+ // to the org's dispatcher /submit), but to the hosted ingress with the gateway
1673
+ // JWT (the Worker derives the org from the token and routes to that org's
1674
+ // container). Any non-2xx throws so the caller's catch fails open.
1675
+ const CONTAINERS_URL = process.env.SYNKRO_CONTAINERS_URL || 'https://containers.synkro.sh';
1676
+ async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeoutMs = 24000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1677
+ const resp = await fetch(CONTAINERS_URL + '/grade/submit', {
1678
+ method: 'POST',
1679
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1680
+ body: JSON.stringify({ role, payload: prompt, content: prompt, agent_kind: agentKind }),
1681
+ signal: AbortSignal.timeout(timeoutMs),
1682
+ });
1683
+ if (!resp.ok) {
1684
+ const text = await resp.text().catch(() => '');
1685
+ throw new Error('hosted grade ' + resp.status + ': ' + text.slice(0, 200));
1686
+ }
1687
+ const data = await resp.json() as { result?: string; error?: string };
1688
+ if (data.error) throw new Error(data.error);
1689
+ return String(data.result || '');
1690
+ }
1691
+
1598
1692
  // Default 24s \u2014 MUST stay below the CC hook timeout (30s for edit/bash/cwe in
1599
1693
  // ccHookConfig.ts) so the AbortSignal fires and the caller's catch fails open
1600
1694
  // cleanly. If this matches or exceeds the CC budget, CC force-kills the bun
@@ -1603,6 +1697,11 @@ async function cloudGrade(surface: string, prompt: string, jwt: string, timeoutM
1603
1697
  export async function localGrade(surface: string, prompt: string, timeoutMs = 24000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1604
1698
  const jwt = loadJwt();
1605
1699
  if (!jwt) throw new Error('NO_JWT');
1700
+ // Cloud-container: the grader runs on Cloudflare Containers \u2014 route to the
1701
+ // hosted ingress instead of the local channel. Same prompt + parseVerdict.
1702
+ if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
1703
+ return hostedGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, timeoutMs, agentKind);
1704
+ }
1606
1705
  // BYOK grading mode routes the grade through an LLM API instead of the
1607
1706
  // on-device channel worker pool. The grader prompt + parseVerdict are shared.
1608
1707
  if ((process.env.SYNKRO_GRADING_MODE || 'local') === 'byok') {
@@ -1618,6 +1717,9 @@ export async function localGrade(surface: string, prompt: string, timeoutMs = 24
1618
1717
  export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code', timeoutMs = 24000): Promise<string> {
1619
1718
  const jwt = loadJwt();
1620
1719
  if (!jwt) throw new Error('NO_JWT');
1720
+ if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
1721
+ return hostedGrade('grade-cwe', prompt, jwt, timeoutMs, agentKind);
1722
+ }
1621
1723
  return channelGrade('grade-cwe', prompt, jwt, 18930, timeoutMs, agentKind);
1622
1724
  }
1623
1725
 
@@ -7491,7 +7593,7 @@ echo ""
7491
7593
  wait $MITM_PID
7492
7594
  `;
7493
7595
  }
7494
- async function runClaudeDesktopTap() {
7596
+ async function runClaudeDesktopTap(opts = {}) {
7495
7597
  if (process.platform !== "darwin") {
7496
7598
  console.log(" Claude Desktop observability is macOS-only \u2014 skipped.");
7497
7599
  return;
@@ -7505,6 +7607,20 @@ async function runClaudeDesktopTap() {
7505
7607
  console.log(" Could not read local auth token (~/.synkro/.mcp-jwt). Run `synkro install` first.");
7506
7608
  return;
7507
7609
  }
7610
+ if (opts.backfill) {
7611
+ console.log(" History backfill enabled \u2014 importing past conversations once this session.");
7612
+ }
7613
+ try {
7614
+ const mcp = installClaudeDesktopMcpConfig();
7615
+ if (mcp.ok) {
7616
+ console.log(` \u2713 Synkro standards MCP registered in Claude Desktop`);
7617
+ console.log(` (${mcp.path}) \u2014 restart Claude Desktop to load the synkro-guardrails tools.`);
7618
+ } else {
7619
+ console.log(` \u26A0 Claude Desktop MCP not registered: ${mcp.reason}`);
7620
+ }
7621
+ } catch (err) {
7622
+ console.log(` \u26A0 Could not register Claude Desktop MCP: ${err.message}`);
7623
+ }
7508
7624
  const cdRoot = join5(homedir5(), ".synkro", "cd-sessions");
7509
7625
  mkdirSync6(cdRoot, { recursive: true });
7510
7626
  const sessionDir = mkdtempSync(join5(cdRoot, "synkro-cd-"));
@@ -7516,7 +7632,7 @@ async function runClaudeDesktopTap() {
7516
7632
  await new Promise((resolve4) => {
7517
7633
  const child = spawn("bash", [runnerPath], {
7518
7634
  stdio: "inherit",
7519
- env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_SCAN_TURN_URL: SCAN_TURN_URL, SYNKRO_DLP_POLICY_URL: DLP_POLICY_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH }
7635
+ env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_SCAN_TURN_URL: SCAN_TURN_URL, SYNKRO_DLP_POLICY_URL: DLP_POLICY_URL, SYNKRO_TURN_VERDICTS_URL: TURN_VERDICTS_URL, SYNKRO_TURN_VERDICT_URL: TURN_VERDICT_URL, SYNKRO_MCP_EVENT_URL: MCP_EVENT_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH, SYNKRO_CD_BACKFILL: opts.backfill ? "1" : "" }
7520
7636
  });
7521
7637
  const forward = (sig) => {
7522
7638
  try {
@@ -7533,23 +7649,39 @@ async function runClaudeDesktopTap() {
7533
7649
  });
7534
7650
  });
7535
7651
  }
7536
- var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7652
+ var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_URL, TURN_VERDICTS_URL, TURN_VERDICT_URL, MCP_EVENT_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7537
7653
  var init_claudeDesktopTap = __esm({
7538
7654
  "cli/installer/claudeDesktopTap.ts"() {
7539
7655
  "use strict";
7656
+ init_mcpConfig();
7540
7657
  CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
7541
7658
  SCAN_URL = "http://127.0.0.1:18931/api/local/scan-upload";
7542
7659
  SCAN_TURN_URL = "http://127.0.0.1:18931/api/local/scan-turn";
7543
7660
  DLP_POLICY_URL = "http://127.0.0.1:18931/api/local/dlp-policy";
7661
+ TURN_VERDICTS_URL = "http://127.0.0.1:18931/api/local/turn-verdicts";
7662
+ TURN_VERDICT_URL = "http://127.0.0.1:18931/api/local/turn-verdict";
7663
+ MCP_EVENT_URL = "http://127.0.0.1:18931/api/local/mcp/event";
7544
7664
  JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7545
- ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64
7665
+ ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64, time, hashlib
7546
7666
  from mitmproxy import http
7547
7667
 
7548
- CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7668
+ def _safe_url(u):
7669
+ # CWE-918: these URLs come from the environment the installer sets \u2014 accept ONLY
7670
+ # the loopback container endpoints, never an arbitrary host from a tampered env.
7671
+ return u if (u.startswith("http://127.0.0.1:") or u.startswith("http://localhost:")) else ""
7672
+
7673
+ CAPTURE_URL = _safe_url(os.environ.get("SYNKRO_CAPTURE_URL", ""))
7549
7674
  TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7550
- SCAN_URL = os.environ.get("SYNKRO_SCAN_URL", "")
7551
- SCAN_TURN_URL = os.environ.get("SYNKRO_SCAN_TURN_URL", "")
7552
- POLICY_URL = os.environ.get("SYNKRO_DLP_POLICY_URL", "")
7675
+ SCAN_URL = _safe_url(os.environ.get("SYNKRO_SCAN_URL", ""))
7676
+ SCAN_TURN_URL = _safe_url(os.environ.get("SYNKRO_SCAN_TURN_URL", ""))
7677
+ POLICY_URL = _safe_url(os.environ.get("SYNKRO_DLP_POLICY_URL", ""))
7678
+ TURN_VERDICTS_URL = _safe_url(os.environ.get("SYNKRO_TURN_VERDICTS_URL", ""))
7679
+ TURN_VERDICT_URL = _safe_url(os.environ.get("SYNKRO_TURN_VERDICT_URL", ""))
7680
+ MCP_EVENT_URL = _safe_url(os.environ.get("SYNKRO_MCP_EVENT_URL", ""))
7681
+ # History backfill is OPT-IN: only when the user runs synkro claude-desktop with
7682
+ # --backfill. Otherwise importing past conversations on every tap launch is wasted
7683
+ # work (and re-floods the feed). When set, it also bypasses the 12h throttle marker.
7684
+ BACKFILL_ENABLED = os.environ.get("SYNKRO_CD_BACKFILL", "") == "1"
7553
7685
  # Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
7554
7686
  # so we re-read it from disk on a 401 instead of needing a tap restart.
7555
7687
  TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
@@ -7615,6 +7747,151 @@ def _fmt_tool_name(raw):
7615
7747
  pass
7616
7748
  return raw
7617
7749
 
7750
+ def _map_full_conversation(all_msgs):
7751
+ # Full conversation [{role, content, tool_calls?}] + 'tool' result messages, in
7752
+ # order, for the session-log tables (the Session Timeline card on the event page).
7753
+ # Lighter than the per-turn capture: role + text + tool_calls + tool results.
7754
+ out = []
7755
+ for msg in all_msgs:
7756
+ sender = msg.get("sender", "")
7757
+ role = "user" if sender == "human" else "assistant"
7758
+ blocks = msg.get("content", [])
7759
+ texts = []; tcs = []; results = []
7760
+ if isinstance(blocks, list):
7761
+ for b in blocks:
7762
+ if isinstance(b, str): texts.append(b); continue
7763
+ if not isinstance(b, dict): continue
7764
+ bt = b.get("type", "")
7765
+ if bt == "text":
7766
+ texts.append(b.get("text", ""))
7767
+ elif bt == "tool_use" or bt == "server_tool_use":
7768
+ tcs.append({"name": _fmt_tool_name(b.get("name", "")), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))[:2000]})
7769
+ elif bt.endswith("_tool_result") or bt == "tool_result":
7770
+ rc = b.get("content", "")
7771
+ if isinstance(rc, list):
7772
+ rc = " | ".join(str(x.get("text") or x.get("content") or "") for x in rc if isinstance(x, dict))
7773
+ if rc and str(rc).strip():
7774
+ results.append({"role": "tool", "content": str(rc)[:8000], "name": _fmt_tool_name(b.get("name", ""))})
7775
+ elif isinstance(blocks, str):
7776
+ texts.append(blocks)
7777
+ content = " ".join(t for t in texts if t).strip()
7778
+ if content or tcs:
7779
+ e = {"role": role, "content": content}
7780
+ if tcs: e["tool_calls"] = tcs
7781
+ out.append(e)
7782
+ for r in results: out.append(r)
7783
+ if len(out) >= 2000: break
7784
+ return out
7785
+
7786
+ def _emit_mcp_event(server, tool, ev_type, eid, convo_id=""):
7787
+ # Post a Claude Desktop connector (MCP) lifecycle/tool_call event to the same
7788
+ # mcp_events store the CC/Cursor mcp-gate writes, so Desktop connectors show on
7789
+ # the MCP Servers page. eid is deterministic so re-captures dedupe (server
7790
+ # ON CONFLICT DO NOTHING). Fire-and-forget; never blocks the proxied request.
7791
+ if not (MCP_EVENT_URL and TOKEN and server): return
7792
+ try:
7793
+ payload = json.dumps({
7794
+ "event_id": eid,
7795
+ "session_id": (str(convo_id)[:80] or None),
7796
+ "server_name": str(server)[:120],
7797
+ "event_type": ev_type,
7798
+ "tool_name": (str(tool)[:120] if tool else None),
7799
+ "decision": ("allowed" if ev_type == "tool_call" else None),
7800
+ "repo": "claude-desktop",
7801
+ }).encode("utf-8")
7802
+ _post_json(MCP_EVENT_URL, payload, 5)
7803
+ except Exception:
7804
+ pass
7805
+
7806
+ # \u2500\u2500 History backfill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7807
+ # One-time pull of the user's past claude.ai conversations into the local store, so
7808
+ # the dashboard shows full Desktop history (like CC's transcript import) \u2014 not just
7809
+ # turns tapped live. Auth (session cookie + org uuid) is captured from a live request
7810
+ # and kept IN MEMORY (never written to disk). Store-only: backfilled turns are not
7811
+ # graded (backfill=True). Runs at most once per 12h via a timestamp marker.
7812
+ #
7813
+ # SSRF (CWE-918): the only host we ever fetch is claude.ai, and org/conversation ids
7814
+ # must be UUID-shaped \u2014 both validated before any URL is built, so no untrusted value
7815
+ # can redirect the fetch to an arbitrary host/path.
7816
+ _backfill_lock = threading.Lock()
7817
+ _backfill_started = False
7818
+ _BACKFILL_MARKER = os.path.expanduser("~/.synkro/.cd-backfill-done")
7819
+
7820
+ def _is_claude_host(h):
7821
+ return h == "claude.ai" or (isinstance(h, str) and h.endswith(".claude.ai"))
7822
+
7823
+ def _is_uuidish(s):
7824
+ return bool(re.match(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", s or ""))
7825
+
7826
+ def _http_get(url, headers, timeout=25):
7827
+ req = urllib.request.Request(url, headers=headers, method="GET")
7828
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
7829
+ return resp.read()
7830
+
7831
+ def _post_backfill_convo(convo, cid):
7832
+ all_msgs = convo.get("chat_messages", [])
7833
+ if not all_msgs: return
7834
+ model = convo.get("model") or "claude-unknown"
7835
+ if not model.startswith("claude-"): model = "claude-" + model
7836
+ session_messages = _map_full_conversation(all_msgs)
7837
+ if not session_messages: return
7838
+ msgs = []
7839
+ for e in session_messages:
7840
+ mm = {"role": e.get("role", "user"), "content": e.get("content", "")}
7841
+ if e.get("tool_calls"): mm["tool_calls"] = e["tool_calls"]
7842
+ msgs.append(mm)
7843
+ # Real conversation timestamp (last activity, fallback created) so the server
7844
+ # stamps created_at to when the convo HAPPENED \u2014 not "now". Without this every
7845
+ # backfill run re-stamps all history to the present and floods it to the top of
7846
+ # the feed. Must be ISO8601; the server re-validates and only honors it for
7847
+ # backfill rows.
7848
+ cts = ""
7849
+ for k in ("updated_at", "created_at"):
7850
+ v = convo.get(k) if isinstance(convo, dict) else None
7851
+ if isinstance(v, str) and re.match(r"^d{4}-d{2}-d{2}T", v):
7852
+ cts = v; break
7853
+ data = {"request": {"model": model, "messages": msgs[-200:]}, "conversation_id": cid,
7854
+ "session_messages": session_messages, "turn_id": "cdbf_" + cid, "backfill": True}
7855
+ if cts: data["event_ts"] = cts
7856
+ _post_json(CAPTURE_URL, json.dumps(data).encode("utf-8"), 8)
7857
+
7858
+ def _backfill(host, org, cookie):
7859
+ if not (_is_claude_host(host) and _is_uuidish(org)): return # defence in depth
7860
+ base = "https://" + host
7861
+ headers = {"Cookie": cookie, "Accept": "application/json", "User-Agent": "synkro-tap"}
7862
+ try:
7863
+ convos = json.loads(_http_get(base + "/api/organizations/" + org + "/chat_conversations?limit=200", headers, 25))
7864
+ if not isinstance(convos, list): return
7865
+ except Exception:
7866
+ with _backfill_lock:
7867
+ globals()["_backfill_started"] = False # allow a later request to retry
7868
+ return
7869
+ print("[synkro] backfill: importing up to " + str(min(len(convos), 200)) + " conversations")
7870
+ n = 0
7871
+ for c in convos[:200]:
7872
+ cid = c.get("uuid", "") if isinstance(c, dict) else ""
7873
+ if not _is_uuidish(cid): continue
7874
+ try:
7875
+ convo = json.loads(_http_get(base + "/api/organizations/" + org + "/chat_conversations/" + cid + "?tree=True&rendering_mode=messages&render_all_tools=true", headers, 25))
7876
+ _post_backfill_convo(convo, cid)
7877
+ n += 1
7878
+ except Exception:
7879
+ pass
7880
+ time.sleep(0.6)
7881
+ try: open(_BACKFILL_MARKER, "w").write(str(int(time.time())))
7882
+ except Exception: pass
7883
+ print("[synkro] backfill done: " + str(n) + " conversations imported")
7884
+
7885
+ def _maybe_backfill(host, org, cookie):
7886
+ # Opt-in only: skip entirely unless the user launched with --backfill.
7887
+ if not BACKFILL_ENABLED: return
7888
+ if not (_is_claude_host(host) and _is_uuidish(org) and cookie and CAPTURE_URL and TOKEN): return
7889
+ with _backfill_lock:
7890
+ global _backfill_started
7891
+ if _backfill_started: return
7892
+ _backfill_started = True
7893
+ threading.Thread(target=_backfill, args=(host, org, cookie), daemon=True).start()
7894
+
7618
7895
  # \u2500\u2500 M3 live-hold state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7619
7896
  # Whether to HOLD connector turns (buffer the live completion until graded).
7620
7897
  # Fetched once from the server: True only when a BLOCK-mode rule exists (+ not
@@ -7705,10 +7982,12 @@ def _extract_sse_text(body):
7705
7982
  if isinstance(x, dict) and isinstance(x.get("text"), str): parts.append(x["text"]); total += len(x["text"])
7706
7983
  return ("".join(parts)[:2000000], tool)
7707
7984
 
7708
- def _neutralize_sse(body, notice):
7709
- # Rewrite a buffered completion SSE: blank every text/content field (inject the
7710
- # notice into the FIRST text delta), leaving all structural events intact so
7711
- # Claude Desktop still renders a valid \u2014 but content-free \u2014 turn.
7985
+ def _neutralize_body(body, notice):
7986
+ # Buffered BLOCK render of a COMPLETE response: replace the answer with the notice
7987
+ # (first text delta, rest blanked), blank text/content content_blocks (incl. the
7988
+ # retrieved doc), leave tool_use + thinking + every structural event intact (the
7989
+ # tree GET scrubs thinking durably). Rewriting a WHOLE buffered body \u2014 not the
7990
+ # live stream \u2014 is what avoids the "couldn't finish" fragility.
7712
7991
  out = []
7713
7992
  injected = [False]
7714
7993
  for line in body.split("\\n"):
@@ -7719,7 +7998,7 @@ def _neutralize_sse(body, notice):
7719
7998
  out.append(line); continue
7720
7999
  changed = False
7721
8000
  d = obj.get("delta")
7722
- if isinstance(d, dict) and isinstance(d.get("text"), str):
8001
+ if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
7723
8002
  d["text"] = (notice if not injected[0] else "")
7724
8003
  injected[0] = True; changed = True
7725
8004
  cb = obj.get("content_block")
@@ -7731,6 +8010,304 @@ def _neutralize_sse(body, notice):
7731
8010
  out.append(("data: " + json.dumps(obj)) if changed else line)
7732
8011
  return "\\n".join(out)
7733
8012
 
8013
+ # Per-conversation DLP verdict cached by the held completion so the tree GET reuses
8014
+ # it (durable scrub) instead of re-grading. Bounded (CWE-400), lock-guarded (CWE-362).
8015
+ _turn_verdicts = {}
8016
+ _tv_lock = threading.Lock()
8017
+
8018
+ def _cache_turn_verdict(cid, verdict):
8019
+ if not cid: return
8020
+ with _tv_lock:
8021
+ if len(_turn_verdicts) > 200: _turn_verdicts.clear()
8022
+ _turn_verdicts[cid] = verdict
8023
+
8024
+ def _pop_turn_verdict(cid):
8025
+ with _tv_lock:
8026
+ return _turn_verdicts.pop(cid, None)
8027
+
8028
+ # \u2500\u2500 Per-turn durable verdict helpers (PGLite-backed via local endpoints) \u2500\u2500\u2500\u2500\u2500\u2500
8029
+ def _msg_blocks(msg):
8030
+ b = msg.get("content", [])
8031
+ return b if isinstance(b, list) else []
8032
+
8033
+ def _msg_key(msg):
8034
+ # Stable per-turn id. Prefer the message uuid; fall back to a content hash so the
8035
+ # verdict still keys consistently across reopens even if uuid is ever absent.
8036
+ u = msg.get("uuid") or msg.get("id")
8037
+ if isinstance(u, str) and u:
8038
+ return u
8039
+ try:
8040
+ raw = json.dumps(msg.get("content", ""), sort_keys=True).encode("utf-8", "replace")
8041
+ return "h:" + hashlib.sha256(raw).hexdigest()[:40]
8042
+ except Exception:
8043
+ return ""
8044
+
8045
+ def _msg_is_connector(msg):
8046
+ # A turn that pulled content via a connector \u2192 has a tool_result block (the doc).
8047
+ for b in _msg_blocks(msg):
8048
+ if isinstance(b, dict) and (b.get("type", "") == "tool_result" or b.get("type", "").endswith("_tool_result")):
8049
+ return True
8050
+ return False
8051
+
8052
+ def _msg_grade_payload(msg):
8053
+ # Extract (assistant_text, tool_result_text, connector) from a message's blocks.
8054
+ texts = []; tools = []; connector = ""
8055
+ for b in _msg_blocks(msg):
8056
+ if isinstance(b, str):
8057
+ texts.append(b); continue
8058
+ if not isinstance(b, dict):
8059
+ continue
8060
+ bt = b.get("type", "")
8061
+ if bt == "text":
8062
+ texts.append(b.get("text", "") or "")
8063
+ elif bt == "tool_use" or bt == "server_tool_use":
8064
+ nm = b.get("name", "") or ""
8065
+ if nm and not connector:
8066
+ connector = nm.split(":")[0] if ":" in nm else nm
8067
+ elif bt == "tool_result" or bt.endswith("_tool_result"):
8068
+ rc = b.get("content", "")
8069
+ if isinstance(rc, list):
8070
+ for x in rc:
8071
+ if isinstance(x, dict):
8072
+ if isinstance(x.get("text"), str): tools.append(x["text"])
8073
+ elif x.get("content"): tools.append(str(x.get("content")))
8074
+ elif isinstance(rc, str):
8075
+ tools.append(rc)
8076
+ ic = b.get("integration_name") or b.get("name") or ""
8077
+ if ic and not connector:
8078
+ connector = ic
8079
+ return ("\\n".join(texts), "\\n".join(tools), connector)
8080
+
8081
+ def _get_turn_verdicts(uuids):
8082
+ # Batch lookup of durable verdicts from PGLite (one round-trip). {uuid: {blocked,reason}}.
8083
+ if not (TURN_VERDICTS_URL and TOKEN and uuids):
8084
+ return {}
8085
+ try:
8086
+ resp = _post_json(TURN_VERDICTS_URL, json.dumps({"uuids": uuids[:500]}).encode("utf-8"), 8)
8087
+ if resp:
8088
+ o = json.loads(resp.decode("utf-8", "replace"))
8089
+ v = o.get("verdicts")
8090
+ return v if isinstance(v, dict) else {}
8091
+ except Exception as e:
8092
+ print("[synkro] verdict lookup error: " + str(e))
8093
+ return {}
8094
+
8095
+ def _put_turn_verdict(uuid, convo_id, blocked, reason):
8096
+ # Persist one graded turn (fire-and-forget, off the tree-GET critical path).
8097
+ if not (TURN_VERDICT_URL and TOKEN and uuid):
8098
+ return
8099
+ def _send():
8100
+ try:
8101
+ _post_json(TURN_VERDICT_URL, json.dumps({"message_uuid": uuid, "convo_id": convo_id, "blocked": bool(blocked), "reason": reason or ""}).encode("utf-8"), 8)
8102
+ except Exception as e:
8103
+ print("[synkro] verdict persist error: " + str(e))
8104
+ threading.Thread(target=_send, daemon=True).start()
8105
+
8106
+ def _intent_before(all_msgs, idx):
8107
+ # Most recent human message text before idx \u2014 the turn's user intent for grading.
8108
+ for j in range(idx - 1, -1, -1):
8109
+ if all_msgs[j].get("sender") == "human":
8110
+ for b in _msg_blocks(all_msgs[j]):
8111
+ if isinstance(b, dict) and b.get("type") == "text": return (b.get("text") or "")[:2000]
8112
+ if isinstance(b, str): return b[:2000]
8113
+ return ""
8114
+ return ""
8115
+
8116
+ def _apply_turn_verdicts(all_msgs, convo_id):
8117
+ # WHOLE-CONVERSATION per-turn DLP scrub. Mutates all_msgs in place: replaces every
8118
+ # BLOCKED assistant turn's content with the notice. Returns True if anything was
8119
+ # blocked. Verdicts are durable (PGLite via the local endpoints); each connector
8120
+ # turn (a tool_result is present) is graded once on a miss then persisted; pure-chat
8121
+ # turns are skipped. Scrubs EVERY blocked turn \u2014 not just the latest \u2014 so a follow-up
8122
+ # can never resurrect an earlier blocked turn. Fail-OPEN.
8123
+ asst_idx = [i for i in range(len(all_msgs)) if all_msgs[i].get("sender") != "human"]
8124
+ keys = {i: _msg_key(all_msgs[i]) for i in asst_idx}
8125
+ ids = [k for k in keys.values() if k]
8126
+ verdicts = _get_turn_verdicts(ids) if ids else {}
8127
+ do_grade = _should_hold() and SCAN_TURN_URL and TOKEN
8128
+ any_blocked = False
8129
+ for i in asst_idx:
8130
+ key = keys.get(i, "")
8131
+ v = verdicts.get(key) if key else None
8132
+ if v is None and do_grade and key and _msg_is_connector(all_msgs[i]):
8133
+ text, tools, connector = _msg_grade_payload(all_msgs[i])
8134
+ if tools.strip() or text.strip():
8135
+ scan_msgs = [
8136
+ {"role": "user", "content": _intent_before(all_msgs, i), "name": ""},
8137
+ {"role": "tool", "content": tools[:500000], "name": connector[:200]},
8138
+ {"role": "assistant", "content": text[:8000], "name": ""},
8139
+ ]
8140
+ reason = _scan_turn_call(convo_id, scan_msgs, connector)
8141
+ v = {"blocked": reason is not None, "reason": reason or ""}
8142
+ _put_turn_verdict(key, convo_id, v["blocked"], v["reason"])
8143
+ if v and v.get("blocked"):
8144
+ all_msgs[i]["content"] = [{"type": "text", "text": "[Blocked by Synkro] " + (v.get("reason") or "restricted data")}]
8145
+ any_blocked = True
8146
+ return any_blocked
8147
+
8148
+ def _feed_answer(chunk, st):
8149
+ # Accumulate the assistant's ANSWER text (text_delta) as the held stream flows,
8150
+ # using a carry for SSE lines split across chunks. The answer reveals the doc's
8151
+ # classification, so once enough has arrived we grade it for an early verdict.
8152
+ txt = st["carry"] + chunk
8153
+ lines = txt.split("\\n")
8154
+ st["carry"] = lines[-1][-100000:] # bound carried partial line (CWE-400)
8155
+ for ln in lines[:-1]:
8156
+ if not ln.startswith("data: "):
8157
+ continue
8158
+ try:
8159
+ obj = json.loads(ln[6:])
8160
+ except Exception:
8161
+ continue
8162
+ d = obj.get("delta")
8163
+ if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
8164
+ if len(st["answer"]) < 200000: # bound (CWE-400)
8165
+ st["answer"] += d["text"]
8166
+
8167
+ def _grade_worker(st, ans):
8168
+ # Runs in a BACKGROUND THREAD (off mitmproxy's event loop). The urllib grade
8169
+ # releases the GIL during I/O, so the loop keeps flushing keep-alive pings.
8170
+ # CWE-362: ans is a snapshot PASSED BY the loop thread (the sole writer of
8171
+ # st["answer"]) at thread-creation \u2014 the worker never reads shared mutable
8172
+ # st["answer"], so there is no data race. It writes only verdict/graded, which the
8173
+ # loop reads AFTER graded.is_set() provides the happens-before barrier.
8174
+ try:
8175
+ ans = ans[:200000]
8176
+ reason = _scan_turn_call(st["cid"], [{"role": "assistant", "content": ans, "name": ""}], "") if ans.strip() else None
8177
+ except Exception:
8178
+ reason = None
8179
+ st["verdict"] = {"block": reason is not None, "reason": reason or ""}
8180
+ _cache_turn_verdict(st["cid"], st["verdict"])
8181
+ st["graded"].set()
8182
+
8183
+ def _classify_frame(fr):
8184
+ # Inspect one CRLF SSE frame's data line \u2192 'tool_use_start' | 'text_start' | 'other'
8185
+ # based on the content_block_start type. Used to find where the FINAL answer begins.
8186
+ for ln in fr.split("\\r\\n"):
8187
+ if ln.startswith("data: "):
8188
+ try:
8189
+ o = json.loads(ln[6:])
8190
+ except Exception:
8191
+ return "other"
8192
+ if o.get("type") == "content_block_start":
8193
+ cb = o.get("content_block")
8194
+ if isinstance(cb, dict):
8195
+ t = cb.get("type")
8196
+ if t == "tool_use":
8197
+ return "tool_use_start"
8198
+ if t == "text":
8199
+ return "text_start"
8200
+ return "other"
8201
+ return "other"
8202
+
8203
+ def _frame_answer_text(fr, st):
8204
+ # Accumulate text_delta text from a HELD frame into st["answer"] (for the grade).
8205
+ for ln in fr.split("\\r\\n"):
8206
+ if ln.startswith("data: "):
8207
+ try:
8208
+ d = json.loads(ln[6:]).get("delta")
8209
+ except Exception:
8210
+ continue
8211
+ if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
8212
+ if len(st["answer"]) < 200000: # CWE-400 bound
8213
+ st["answer"] += d["text"]
8214
+
8215
+ def _block_held(frames, notice):
8216
+ # Frame-aware, CRLF-PRESERVING block render of the held final-answer frames: replace
8217
+ # the answer's text_delta(s) (first\u2192notice, rest\u2192"") and leave every structural frame
8218
+ # (content_block_stop / message_delta / message_stop) intact so the message still
8219
+ # finalizes cleanly. Operates per-frame on \\r\\n lines \u2192 no \\n/\\r\\n mixing, so the
8220
+ # framing stays byte-clean (the thing _neutralize_body's \\n split would have broken).
8221
+ inj = [False]
8222
+ out = []
8223
+ for fr in frames:
8224
+ nl = []
8225
+ for ln in fr.split("\\r\\n"):
8226
+ if ln.startswith("data: "):
8227
+ try:
8228
+ o = json.loads(ln[6:])
8229
+ d = o.get("delta")
8230
+ if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
8231
+ o["delta"]["text"] = notice if not inj[0] else ""
8232
+ inj[0] = True
8233
+ ln = "data: " + json.dumps(o)
8234
+ except Exception:
8235
+ pass
8236
+ nl.append(ln)
8237
+ out.append("\\r\\n".join(nl))
8238
+ return ("\\r\\n\\r\\n".join(out) + "\\r\\n\\r\\n") if out else ""
8239
+
8240
+ def _make_hold_stream(cid):
8241
+ # Pass-through-until-FINAL-ANSWER hold for the claude.ai completion (CRLF SSE; frames
8242
+ # split by \\r\\n\\r\\n). A connector turn is: thinking \u2192 text(preamble) \u2192 tool_use \u2192
8243
+ # tool_result \u2192 \u2026 \u2192 text(ANSWER). Only the FINAL answer is in the user's face, so we
8244
+ # STREAM every earlier frame VERBATIM (natural thinking/search activity + tool chips,
8245
+ # zero mutation) and HOLD only the final answer \u2014 the text block that opens AFTER a
8246
+ # tool_use \u2014 through message_stop. Buffer it (no leak), grade off-thread, heartbeat to
8247
+ # keep Desktop alive, then emit the real answer (clean) or a frame-aware block rewrite
8248
+ # (restricted). CWE-362: the grade thread gets an immutable answer snapshot; the
8249
+ # verdict returns via the Event barrier; the loop is the sole writer of held/answer.
8250
+ st = {"cid": cid, "carry": "", "holding": False, "seen_tool": False,
8251
+ "held": [], "held_size": 0, "answer": "", "started": False, "meta": _new_scan_state(),
8252
+ "graded": threading.Event(), "verdict": {"block": False, "reason": ""}}
8253
+ def _hold(data, _st=st):
8254
+ if data:
8255
+ txt = _st["carry"] + data.decode("utf-8", "replace")
8256
+ frames = txt.split("\\r\\n\\r\\n")
8257
+ _st["carry"] = frames[-1][-200000:] # CWE-400 bounded carry (last partial frame)
8258
+ out = []
8259
+ for fr in frames[:-1]:
8260
+ for ln in fr.split("\\r\\n"):
8261
+ _scan_sse_line(ln, _st["meta"]) # governance metadata, all frames
8262
+ if not _st["holding"]:
8263
+ k = _classify_frame(fr)
8264
+ if k == "tool_use_start":
8265
+ _st["seen_tool"] = True
8266
+ if k == "text_start" and _st["seen_tool"]:
8267
+ _st["holding"] = True # the FINAL answer begins here \u2192 hold from now
8268
+ else:
8269
+ out.append(fr) # thinking / preamble / tool_use / tool_result \u2192 verbatim
8270
+ continue
8271
+ if _st["held_size"] < 8000000: # CWE-400 bound before append
8272
+ _st["held"].append(fr); _st["held_size"] += len(fr)
8273
+ _frame_answer_text(fr, _st)
8274
+ if _st["holding"] and not _st["started"] and len(_st["answer"]) >= 1200:
8275
+ _st["started"] = True
8276
+ threading.Thread(target=_grade_worker, args=(_st, _st["answer"][:200000]), daemon=True).start()
8277
+ if out:
8278
+ return ("\\r\\n\\r\\n".join(out) + "\\r\\n\\r\\n").encode("utf-8") # stream activity verbatim
8279
+ return b": synkro\\n\\n" # answer held \u2192 heartbeat (keeps Desktop alive, PROVEN)
8280
+ # upstream ended
8281
+ m = _st["meta"]
8282
+ if (m["usage"] or m["sources"] or m["citations"]) and _st["cid"]:
8283
+ with _sse_lock:
8284
+ _sse_meta[_st["cid"]] = {"usage": m["usage"], "sources": m["sources"], "citations": m["citations"]}
8285
+ if not _st["holding"]: # no final-answer block detected \u2014 everything already streamed
8286
+ return _st["carry"].encode("utf-8") if _st["carry"] else b""
8287
+ if _st["carry"]:
8288
+ _st["held"].append(_st["carry"]); _frame_answer_text(_st["carry"], _st); _st["carry"] = ""
8289
+ if not _st["started"] and _st["answer"].strip(): # short answer never hit the 1200 trigger
8290
+ _st["started"] = True
8291
+ threading.Thread(target=_grade_worker, args=(_st, _st["answer"][:200000]), daemon=True).start()
8292
+ held_frames = list(_st["held"])
8293
+ def _grade_window(_st2=_st, _frames=held_frames):
8294
+ waited = 0.0
8295
+ while not _st2["graded"].is_set() and waited < 60.0:
8296
+ yield b": synkro\\n\\n" # heartbeat WHILE grading \u2014 keeps Desktop alive (PROVEN)
8297
+ time.sleep(0.5)
8298
+ waited += 0.5
8299
+ v = _st2["verdict"]
8300
+ if _st2["graded"].is_set() and v["block"]:
8301
+ print("[synkro] HELD+BLOCKED live (reason=" + v["reason"][:60] + ")")
8302
+ yield _block_held(_frames, "[Blocked by Synkro] " + v["reason"]).encode("utf-8")
8303
+ elif _st2["graded"].is_set():
8304
+ yield ("\\r\\n\\r\\n".join(_frames) + "\\r\\n\\r\\n").encode("utf-8") # clean \u2192 real answer
8305
+ else: # grade never landed in 60s \u2014 never leak; tree GET backstop finalizes
8306
+ print("[synkro] hold: grade timeout \u2192 placeholder + tree-GET backstop")
8307
+ yield _block_held(_frames, "[Synkro is reviewing this response\u2026]").encode("utf-8")
8308
+ return _grade_window()
8309
+ return _hold
8310
+
7734
8311
  class SynkroTap:
7735
8312
  def request(self, flow):
7736
8313
  # \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
@@ -7785,9 +8362,16 @@ class SynkroTap:
7785
8362
  return
7786
8363
  if verdict.get("block"):
7787
8364
  reason = str(verdict.get("reason", "Blocked by Synkro policy"))[:300]
8365
+ msg = "Blocked by Synkro: " + reason
8366
+ # Return a TERMINAL 400 (invalid_request_error), NOT 403. Claude Desktop's
8367
+ # upload client treats 403 as transient/auth and auto-RETRIES it \u2014 that's the
8368
+ # loop the user sees. A 400 invalid_request_error is non-retryable (the SDK/UI
8369
+ # only retry 408/409/429/5xx), so the client stops and surfaces the message.
8370
+ # Anthropic's standard error envelope ({"type":"error","error":{...}}) is what
8371
+ # the UI knows how to render, so the user sees the reason instead of a spinner.
7788
8372
  flow.response = http.Response.make(
7789
- 403,
7790
- json.dumps({"error": {"type": "blocked_by_synkro", "message": "Blocked by Synkro: " + reason}}).encode(),
8373
+ 400,
8374
+ json.dumps({"type": "error", "error": {"type": "invalid_request_error", "message": msg}}).encode(),
7791
8375
  {"Content-Type": "application/json"},
7792
8376
  )
7793
8377
  print("[synkro] BLOCKED upload (size=" + str(payload.get("size", 0)) + " rule=" + str(verdict.get("ruleId", "")) + ")")
@@ -7795,13 +8379,14 @@ class SynkroTap:
7795
8379
  print("[synkro] upload scanned -> allow (size=" + str(payload.get("size", 0)) + ")")
7796
8380
 
7797
8381
  def responseheaders(self, flow):
7798
- # Buffer the small conversation-tree GET (parsed for the dashboard).
7799
- # For the completion POST, TAP the SSE chunk-by-chunk and pass each
7800
- # through unchanged \u2014 Claude Desktop keeps streaming normally while we
7801
- # accumulate a bounded copy for the raw diagnostic dump. Everything else
7802
- # streams untouched.
8382
+ # The answer the user watches TYPE OUT comes from claude.ai's completion \u2014 THAT
8383
+ # is the stream we HOLD (buffer + heartbeat \u2192 no leak, no disconnect; see
8384
+ # _make_hold_stream). a-api.anthropic.com is a background call that doesn't carry
8385
+ # the answer, so it streams through untouched (default below). The tree GET is
8386
+ # buffered for the dashboard + the durable block scrub.
7803
8387
  r = flow.request
7804
- if "claude.ai" in (r.pretty_host or "") and "chat_conversations" in r.path:
8388
+ host = r.pretty_host or ""
8389
+ if "claude.ai" in host and "chat_conversations" in r.path:
7805
8390
  if r.method == "GET" and "tree" in r.pretty_url:
7806
8391
  return
7807
8392
  if r.method == "POST" and "completion" in r.path:
@@ -7809,16 +8394,12 @@ class SynkroTap:
7809
8394
  cid = cm.group(1) if cm else ""
7810
8395
  with _mcp_lock:
7811
8396
  has_servers = len(_mcp_registry) > 0
7812
- # M3 LIVE HOLD: only when a BLOCK-mode rule exists AND connectors are
7813
- # connected, BUFFER the whole completion (leave .stream unset) so
7814
- # response() can grade it and neutralize restricted content BEFORE the
7815
- # user sees a single token. Audit-only / no-connector turns skip this
7816
- # entirely and stream as before \u2014 zero added latency.
8397
+ # HOLD when a BLOCK-mode rule exists AND connectors are connected \u2014 buffer
8398
+ # the answer, grade off-thread, heartbeat through the grade, emit the verdict.
7817
8399
  if has_servers and _should_hold():
7818
- flow.metadata["synkro_hold_cid"] = cid
8400
+ flow.response.stream = _make_hold_stream(cid)
7819
8401
  return
7820
- # Streaming path (unchanged behaviour): tap chunk-by-chunk, extract
7821
- # governance metadata only, never buffer the body. Audit stays async.
8402
+ # Otherwise just TAP for governance metadata (no hold; audit stays async).
7822
8403
  st = _new_scan_state()
7823
8404
  def _tap(data, _st=st, _cid=cid):
7824
8405
  if data:
@@ -7845,36 +8426,6 @@ class SynkroTap:
7845
8426
  def response(self, flow):
7846
8427
  r = flow.request
7847
8428
  if "claude.ai" not in (r.pretty_host or ""): return
7848
- # \u2500\u2500 M3: a HELD completion (buffered for inline grading) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7849
- # We have the full SSE body now. Re-derive governance metadata (same as the
7850
- # streaming path), then \u2014 if the turn used a tool/connector \u2014 grade it
7851
- # SYNCHRONOUSLY and, on a block, NEUTRALIZE the response so the user never
7852
- # sees the restricted content. Verdict is cached for the tree GET to reuse.
7853
- hold_cid = (flow.metadata.get("synkro_hold_cid") if hasattr(flow, "metadata") else None)
7854
- if hold_cid is not None and "completion" in r.path and flow.response:
7855
- try:
7856
- body = flow.response.get_text() or ""
7857
- except Exception:
7858
- body = ""
7859
- st = _new_scan_state()
7860
- for ln in body.split("\\n"):
7861
- _scan_sse_line(ln, st)
7862
- if (st["usage"] or st["sources"] or st["citations"]) and hold_cid:
7863
- with _sse_lock:
7864
- _sse_meta[hold_cid] = {"usage": st["usage"], "sources": st["sources"], "citations": st["citations"]}
7865
- text, has_tool = _extract_sse_text(body)
7866
- if has_tool:
7867
- # The turn used a connector \u2014 HOLD the live view: replace the streamed
7868
- # content with a "reviewing" placeholder so the user never sees the raw
7869
- # response. The tree GET is the SINGLE grader and renders the final
7870
- # state (real content if clean, block notice if restricted) \u2014 so there
7871
- # is exactly one full-document grade per turn (no double-grade race).
7872
- try:
7873
- flow.response.text = _neutralize_sse(body, REVIEWING_NOTICE)
7874
- print("[synkro] HELD completion -> reviewing placeholder")
7875
- except Exception as e:
7876
- print("[synkro] hold placeholder failed (fail-open): " + str(e))
7877
- return
7878
8429
  # MCP server registry from the bootstrap SSE stream.
7879
8430
  if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7880
8431
  try:
@@ -7897,6 +8448,12 @@ class SynkroTap:
7897
8448
  _mcp_registry.clear()
7898
8449
  _mcp_registry.update(servers)
7899
8450
  print("[synkro] MCP servers: " + str(len(servers)))
8451
+ # Surface each connector on the MCP Servers page (connect/disconnect).
8452
+ for sid, s in servers.items():
8453
+ nm = s.get("name", "")
8454
+ if nm:
8455
+ ev = "connect" if s.get("connected") else "disconnect"
8456
+ _emit_mcp_event(nm, "", ev, "cdmcp_" + ev + "_" + str(sid))
7900
8457
  except Exception:
7901
8458
  pass
7902
8459
  return
@@ -7906,6 +8463,12 @@ class SynkroTap:
7906
8463
  except: return
7907
8464
  m = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7908
8465
  convo_id = m.group(1) if m else ""
8466
+ # One-time history backfill using THIS request's session auth (in-memory only).
8467
+ try:
8468
+ _om = re.search(r"/organizations/([0-9a-fA-F-]+)/", r.path)
8469
+ _maybe_backfill(r.pretty_host, _om.group(1) if _om else "", r.headers.get("cookie", "") or r.headers.get("Cookie", ""))
8470
+ except Exception:
8471
+ pass
7909
8472
  model = convo.get("model", "unknown")
7910
8473
  settings = convo.get("settings") or {}
7911
8474
  emt = settings.get("enabled_mcp_tools")
@@ -7938,7 +8501,14 @@ class SynkroTap:
7938
8501
  elif bt == "thinking":
7939
8502
  thinking += b.get("thinking", "") or b.get("text", "")
7940
8503
  elif bt == "tool_use" or bt == "server_tool_use":
7941
- tool_calls.append({"name": _fmt_tool_name(b.get("name", "")), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
8504
+ _nm = _fmt_tool_name(b.get("name", ""))
8505
+ tool_calls.append({"name": _nm, "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
8506
+ # A "Server:tool" name means a hosted MCP connector ran \u2192 log it
8507
+ # on the MCP Servers page (":" only present for registry matches,
8508
+ # so built-in tools like web_search are excluded).
8509
+ if ":" in _nm:
8510
+ _srv, _, _tl = _nm.partition(":")
8511
+ _emit_mcp_event(_srv, _tl, "tool_call", "cdmcp_tc_" + (b.get("id", "") or hashlib.sha256((convo_id + _nm).encode()).hexdigest()[:24]), convo_id)
7942
8512
  elif bt.endswith("_tool_result") or bt == "tool_result":
7943
8513
  rc = b.get("content", "")
7944
8514
  if isinstance(rc, list):
@@ -7975,6 +8545,16 @@ class SynkroTap:
7975
8545
  messages.append({"role": "user", "content": text})
7976
8546
  if not messages: return
7977
8547
  data = {"request": {"model": model, "messages": messages}, "conversation_id": convo_id}
8548
+ # Full conversation for the session-log tables (Session Timeline card).
8549
+ data["session_messages"] = _map_full_conversation(all_msgs)
8550
+ # PER-TURN key: the current turn's assistant message id (same key used for
8551
+ # dlp_turn_verdicts). Lets the server store each turn as its OWN event so a
8552
+ # blocked turn isn't overwritten/deleted by a later passing turn.
8553
+ turn_id = ""
8554
+ for _m in reversed(chat):
8555
+ if _m.get("sender") != "human":
8556
+ turn_id = _msg_key(_m); break
8557
+ if turn_id: data["turn_id"] = turn_id
7978
8558
  meta = {}
7979
8559
  if platform: meta["platform"] = platform
7980
8560
  if web_search_on: meta["web_search_enabled"] = True
@@ -7992,32 +8572,14 @@ class SynkroTap:
7992
8572
  nsrc = len((sm or {}).get("sources") or [])
7993
8573
  print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7994
8574
 
7995
- # \u2500\u2500 Tier-2 connector DLP gate \u2500\u2500 If this turn pulled content via an MCP
7996
- # connector, scan it SYNCHRONOUSLY against blocking org rules. On a block
7997
- # verdict we HOLD this tree GET (the authoritative render) until the verdict
7998
- # and then SCRUB the turn from the tree the renderer sees \u2014 the employee
7999
- # never sees the restricted content. The REAL content is still captured
8000
- # below for audit (record-everything-even-when-blocked). Fail-OPEN.
8001
- blocked_reason = None
8002
- has_connector = any(m.get("role") == "tool" and m.get("content") for m in messages)
8003
- # SINGLE grader: the held completion only shows a "reviewing" placeholder, so
8004
- # the tree GET is the one and only place a turn is graded \u2014 exactly one
8005
- # full-document grade per turn (no double-grade). Gated on a BLOCK-mode rule
8006
- # existing; audit-only orgs (_should_hold False) skip this and stay async.
8007
- if _should_hold() and SCAN_TURN_URL and TOKEN and has_connector:
8008
- # CWE-20/CWE-400: bounded, shape-validated payload (<=200 msgs, <=2MB).
8009
- scan_msgs = []
8010
- budget = 2000000
8011
- for m in messages[:200]:
8012
- if budget <= 0: break
8013
- c = str(m.get("content", ""))[:min(500000, budget)]
8014
- budget -= len(c)
8015
- scan_msgs.append({"role": str(m.get("role", "user"))[:20], "content": c, "name": str(m.get("name", ""))[:200]})
8016
- connector = ""
8017
- for m in scan_msgs:
8018
- if m["role"] == "tool" and m["name"]:
8019
- nm = m["name"]; connector = (nm.split(":")[0] if ":" in nm else nm); break
8020
- blocked_reason = _scan_turn_call(convo_id, scan_msgs, connector)
8575
+ # \u2500\u2500 Tier-2 connector DLP gate (WHOLE-CONVERSATION, per-turn, PERSISTENT) \u2500\u2500
8576
+ # Scrub every blocked assistant turn (durable PGLite verdicts; connector turns
8577
+ # graded once on miss). See _apply_turn_verdicts. Fail-OPEN on any error.
8578
+ try:
8579
+ any_blocked = _apply_turn_verdicts(all_msgs, convo_id)
8580
+ except Exception as e:
8581
+ any_blocked = False
8582
+ print("[synkro] turn-verdict scrub error (fail-open): " + str(e))
8021
8583
 
8022
8584
  # Capture the REAL content for audit regardless of the block decision.
8023
8585
  def send(_d=data):
@@ -8027,17 +8589,11 @@ class SynkroTap:
8027
8589
  print("[synkro] capture error: " + str(e))
8028
8590
  threading.Thread(target=send, daemon=True).start()
8029
8591
 
8030
- # Block the rendered view: replace this turn's assistant message content in
8031
- # the tree (everything after the last human turn) with a Synkro notice, so
8032
- # the restricted content + any model output derived from it is never shown.
8033
- if blocked_reason is not None:
8034
- notice = "[Blocked by Synkro] " + blocked_reason
8035
- for i in range(last_human + 1, len(all_msgs)):
8036
- if all_msgs[i].get("sender") != "human":
8037
- all_msgs[i]["content"] = [{"type": "text", "text": notice}]
8592
+ # Re-serialize the tree so the renderer never sees any blocked turn's content.
8593
+ if any_blocked:
8038
8594
  try:
8039
8595
  flow.response.text = json.dumps(convo)
8040
- print("[synkro] BLOCKED turn view (reason=" + blocked_reason[:80] + ")")
8596
+ print("[synkro] BLOCKED turn view (whole-convo per-turn, durable)")
8041
8597
  except Exception as e:
8042
8598
  print("[synkro] block scrub failed (fail-open): " + str(e))
8043
8599
 
@@ -8221,16 +8777,16 @@ __export(macKeychain_exports, {
8221
8777
  writeCursorApiKey: () => writeCursorApiKey,
8222
8778
  writeRefreshAgent: () => writeRefreshAgent
8223
8779
  });
8224
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
8780
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6 } from "fs";
8225
8781
  import { homedir as homedir6, platform as platform2 } from "os";
8226
8782
  import { join as join6 } from "path";
8227
- import { spawnSync } from "child_process";
8783
+ import { spawnSync as spawnSync2 } from "child_process";
8228
8784
  function needsKeychainBridge() {
8229
8785
  return platform2() === "darwin";
8230
8786
  }
8231
8787
  function readKeychainCreds() {
8232
8788
  if (platform2() !== "darwin") return null;
8233
- const r = spawnSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], {
8789
+ const r = spawnSync2("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], {
8234
8790
  encoding: "utf-8",
8235
8791
  timeout: 5e3
8236
8792
  });
@@ -8243,7 +8799,14 @@ function exportKeychainCreds() {
8243
8799
  if (!blob) return null;
8244
8800
  mkdirSync7(CLAUDE_CREDS_DIR, { recursive: true });
8245
8801
  chmodSync(CLAUDE_CREDS_DIR, 448);
8246
- writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
8802
+ let changed = true;
8803
+ try {
8804
+ if (existsSync8(CLAUDE_CREDS_FILE)) {
8805
+ changed = readFileSync6(CLAUDE_CREDS_FILE, "utf-8") !== blob;
8806
+ }
8807
+ } catch {
8808
+ }
8809
+ if (changed) writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
8247
8810
  chmodSync(CLAUDE_CREDS_FILE, 384);
8248
8811
  return CLAUDE_CREDS_FILE;
8249
8812
  }
@@ -8286,18 +8849,49 @@ async function validateCursorApiKey() {
8286
8849
  function credsAreStale() {
8287
8850
  if (!existsSync8(CLAUDE_CREDS_FILE)) return true;
8288
8851
  try {
8289
- const ageMs = Date.now() - statSync(CLAUDE_CREDS_FILE).mtimeMs;
8290
- return ageMs > REFRESH_INTERVAL_SECONDS * 1e3;
8852
+ const raw = readFileSync6(CLAUDE_CREDS_FILE, "utf-8");
8853
+ const exp = JSON.parse(raw)?.claudeAiOauth?.expiresAt ?? 0;
8854
+ if (!exp) return true;
8855
+ return Date.now() >= exp - REFRESH_EXPIRY_BUFFER_SECONDS * 1e3;
8291
8856
  } catch {
8292
8857
  return true;
8293
8858
  }
8294
8859
  }
8860
+ function xmlEscape(s) {
8861
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
8862
+ }
8295
8863
  function writeRefreshAgent(synkroBinPath) {
8296
8864
  if (platform2() !== "darwin") {
8297
8865
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
8298
8866
  }
8299
8867
  mkdirSync7(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
8300
- const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
8868
+ mkdirSync7(SYNKRO_DIR, { recursive: true });
8869
+ const script = `#!/bin/bash
8870
+ # Generated by synkro (writeRefreshAgent). Expiry-aware Claude creds refresher.
8871
+ # SYNKRO_BIN / SYNKRO_CF are injected by launchd as environment data \u2014 never
8872
+ # interpolated into this script \u2014 so there is no shell-injection surface.
8873
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
8874
+ prev=""
8875
+ while true; do
8876
+ { "$SYNKRO_BIN" local-cc refresh-creds || synkro local-cc refresh-creds; } >/dev/null 2>&1
8877
+ exp=$(sed -n 's/.*"expiresAt"[: ]*\\([0-9]\\{10,\\}\\).*/\\1/p' "$SYNKRO_CF" 2>/dev/null | head -1)
8878
+ now=$(date +%s)
8879
+ if [ -n "$exp" ]; then
8880
+ delay=$(( exp/1000 - now - ${REFRESH_EXPIRY_BUFFER_SECONDS} ))
8881
+ else
8882
+ delay=${MIN_REFRESH_INTERVAL_SECONDS}
8883
+ fi
8884
+ [ "$delay" -lt ${MIN_REFRESH_INTERVAL_SECONDS} ] && delay=${MIN_REFRESH_INTERVAL_SECONDS}
8885
+ [ "$delay" -gt ${MAX_REFRESH_INTERVAL_SECONDS} ] && delay=${MAX_REFRESH_INTERVAL_SECONDS}
8886
+ if [ "$exp" != "$prev" ]; then
8887
+ echo "[$(date '+%Y-%m-%dT%H:%M:%S')] claude creds refreshed (expiresAt=\${exp:-none}); next check in \${delay}s"
8888
+ prev="$exp"
8889
+ fi
8890
+ sleep "$delay"
8891
+ done
8892
+ `;
8893
+ writeFileSync7(REFRESH_SCRIPT, script, "utf-8");
8894
+ chmodSync(REFRESH_SCRIPT, 448);
8301
8895
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
8302
8896
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
8303
8897
  <plist version="1.0">
@@ -8307,17 +8901,25 @@ function writeRefreshAgent(synkroBinPath) {
8307
8901
  <key>ProgramArguments</key>
8308
8902
  <array>
8309
8903
  <string>/bin/bash</string>
8310
- <string>-c</string>
8311
- <string>${shellCmd}</string>
8904
+ <string>${xmlEscape(REFRESH_SCRIPT)}</string>
8312
8905
  </array>
8313
- <key>StartInterval</key>
8314
- <integer>${REFRESH_INTERVAL_SECONDS}</integer>
8906
+ <key>EnvironmentVariables</key>
8907
+ <dict>
8908
+ <key>SYNKRO_BIN</key>
8909
+ <string>${xmlEscape(synkroBinPath)}</string>
8910
+ <key>SYNKRO_CF</key>
8911
+ <string>${xmlEscape(CLAUDE_CREDS_FILE)}</string>
8912
+ </dict>
8913
+ <key>KeepAlive</key>
8914
+ <true/>
8315
8915
  <key>RunAtLoad</key>
8316
8916
  <true/>
8917
+ <key>ThrottleInterval</key>
8918
+ <integer>${MIN_REFRESH_INTERVAL_SECONDS}</integer>
8317
8919
  <key>StandardErrorPath</key>
8318
- <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
8920
+ <string>${xmlEscape(REFRESH_LOG)}</string>
8319
8921
  <key>StandardOutPath</key>
8320
- <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
8922
+ <string>${xmlEscape(REFRESH_LOG)}</string>
8321
8923
  </dict>
8322
8924
  </plist>
8323
8925
  `;
@@ -8326,11 +8928,11 @@ function writeRefreshAgent(synkroBinPath) {
8326
8928
  }
8327
8929
  function loadRefreshAgent() {
8328
8930
  if (platform2() !== "darwin") return;
8329
- spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8931
+ spawnSync2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8330
8932
  encoding: "utf-8",
8331
8933
  timeout: 5e3
8332
8934
  });
8333
- const r = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8935
+ const r = spawnSync2("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8334
8936
  encoding: "utf-8",
8335
8937
  timeout: 5e3
8336
8938
  });
@@ -8342,14 +8944,14 @@ function loadRefreshAgent() {
8342
8944
  }
8343
8945
  function uninstallRefreshAgent() {
8344
8946
  if (platform2() !== "darwin") return;
8345
- spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8947
+ spawnSync2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
8346
8948
  encoding: "utf-8",
8347
8949
  timeout: 5e3
8348
8950
  });
8349
8951
  try {
8350
- if (existsSync8(LAUNCHD_PLIST)) {
8351
- __require("fs").unlinkSync(LAUNCHD_PLIST);
8352
- }
8952
+ const fs = __require("fs");
8953
+ if (existsSync8(LAUNCHD_PLIST)) fs.unlinkSync(LAUNCHD_PLIST);
8954
+ if (existsSync8(REFRESH_SCRIPT)) fs.unlinkSync(REFRESH_SCRIPT);
8353
8955
  } catch {
8354
8956
  }
8355
8957
  }
@@ -8364,7 +8966,7 @@ function readExportedCreds() {
8364
8966
  return null;
8365
8967
  }
8366
8968
  }
8367
- var SYNKRO_DIR, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, CURSOR_CREDS_DIR, CURSOR_API_KEY_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
8969
+ var SYNKRO_DIR, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, CURSOR_CREDS_DIR, CURSOR_API_KEY_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_SCRIPT, REFRESH_LOG, REFRESH_EXPIRY_BUFFER_SECONDS, MIN_REFRESH_INTERVAL_SECONDS, MAX_REFRESH_INTERVAL_SECONDS, KeychainExportError;
8368
8970
  var init_macKeychain = __esm({
8369
8971
  "cli/local-cc/macKeychain.ts"() {
8370
8972
  "use strict";
@@ -8376,7 +8978,11 @@ var init_macKeychain = __esm({
8376
8978
  KEYCHAIN_SERVICE = "Claude Code-credentials";
8377
8979
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
8378
8980
  LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
8379
- REFRESH_INTERVAL_SECONDS = 45 * 60;
8981
+ REFRESH_SCRIPT = join6(SYNKRO_DIR, "claude-creds-refresh-loop.sh");
8982
+ REFRESH_LOG = join6(SYNKRO_DIR, "claude-creds-refresh.log");
8983
+ REFRESH_EXPIRY_BUFFER_SECONDS = 120;
8984
+ MIN_REFRESH_INTERVAL_SECONDS = 30;
8985
+ MAX_REFRESH_INTERVAL_SECONDS = 5 * 60;
8380
8986
  KeychainExportError = class extends Error {
8381
8987
  constructor(message, cause) {
8382
8988
  super(message);
@@ -8415,7 +9021,7 @@ __export(dockerInstall_exports, {
8415
9021
  import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
8416
9022
  import { homedir as homedir7 } from "os";
8417
9023
  import { join as join7 } from "path";
8418
- import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
9024
+ import { execSync as execSync4, spawnSync as spawnSync3 } from "child_process";
8419
9025
  function splitWorkers(total, providers) {
8420
9026
  const t = Math.max(0, Math.floor(total));
8421
9027
  const hasClaude = providers.includes("claude_code");
@@ -8574,7 +9180,7 @@ function imageTag() {
8574
9180
  return registry ? `${registry.replace(/\/+$/, "")}/${tag.replace(/^.*\//, "")}` : tag;
8575
9181
  }
8576
9182
  function assertDockerAvailable() {
8577
- const v = spawnSync2("docker", ["version", "--format", "{{.Server.Version}}"], {
9183
+ const v = spawnSync3("docker", ["version", "--format", "{{.Server.Version}}"], {
8578
9184
  encoding: "utf-8",
8579
9185
  timeout: 5e3
8580
9186
  });
@@ -8589,12 +9195,12 @@ function claudeCredsHostDir() {
8589
9195
  return join7(homedir7(), ".claude");
8590
9196
  }
8591
9197
  function resolveSynkroBin() {
8592
- const which2 = spawnSync2("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
9198
+ const which2 = spawnSync3("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
8593
9199
  const resolved = (which2.stdout || "").split("\n")[0].trim();
8594
9200
  return resolved || "synkro";
8595
9201
  }
8596
9202
  function sweepHostPglite() {
8597
- const ps = spawnSync2("ps", ["-eo", "pid,command"], { encoding: "utf-8", timeout: 5e3 });
9203
+ const ps = spawnSync3("ps", ["-eo", "pid,command"], { encoding: "utf-8", timeout: 5e3 });
8598
9204
  if (ps.status !== 0) return;
8599
9205
  const targets = [];
8600
9206
  for (const line of (ps.stdout || "").split("\n")) {
@@ -8624,7 +9230,7 @@ function sweepHostPglite() {
8624
9230
  }
8625
9231
  });
8626
9232
  if (alive.length === 0) return;
8627
- spawnSync2("sleep", ["0.2"], { timeout: 1e3 });
9233
+ spawnSync3("sleep", ["0.2"], { timeout: 1e3 });
8628
9234
  }
8629
9235
  for (const pid of targets) {
8630
9236
  try {
@@ -8676,13 +9282,13 @@ async function dockerInstall(opts = {}) {
8676
9282
  } else {
8677
9283
  mkdirSync8(join7(homedir7(), ".claude"), { recursive: true });
8678
9284
  }
8679
- const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
9285
+ const imageExistsLocally = () => spawnSync3("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
8680
9286
  const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
8681
9287
  if (skipPull && imageExistsLocally()) {
8682
9288
  console.log(` Using local image ${image} (SYNKRO_SKIP_PULL set \u2014 pull skipped).`);
8683
9289
  } else {
8684
9290
  console.log(` Pulling ${image}...`);
8685
- const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
9291
+ const pull = spawnSync3("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
8686
9292
  if (pull.status !== 0) {
8687
9293
  if (imageExistsLocally()) {
8688
9294
  console.warn(` \u26A0 docker pull ${image} failed \u2014 falling back to the existing local image.`);
@@ -8696,7 +9302,7 @@ async function dockerInstall(opts = {}) {
8696
9302
  console.log(" Stopping existing container gracefully...");
8697
9303
  await dockerSafeStop();
8698
9304
  }
8699
- spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
9305
+ spawnSync3("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
8700
9306
  sweepHostPglite();
8701
9307
  const credsDir = claudeCredsHostDir();
8702
9308
  const args2 = [
@@ -8754,7 +9360,7 @@ async function dockerInstall(opts = {}) {
8754
9360
  ...process.env.SYNKRO_EMAIL ? ["-e", `SYNKRO_EMAIL=${process.env.SYNKRO_EMAIL}`] : [],
8755
9361
  image
8756
9362
  ];
8757
- const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
9363
+ const run = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
8758
9364
  if (run.status !== 0) {
8759
9365
  throw new DockerInstallError(`docker run failed (image ${image})`);
8760
9366
  }
@@ -8790,11 +9396,11 @@ async function waitForWorkersReady(timeoutMs = 6e4) {
8790
9396
  return false;
8791
9397
  }
8792
9398
  function dockerRemove() {
8793
- spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
9399
+ spawnSync3("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
8794
9400
  }
8795
9401
  function dockerStop() {
8796
- spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
8797
- spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
9402
+ spawnSync3("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
9403
+ spawnSync3("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
8798
9404
  }
8799
9405
  async function dockerUpdate(opts = {}) {
8800
9406
  if (dockerStatus().running) {
@@ -8804,7 +9410,7 @@ async function dockerUpdate(opts = {}) {
8804
9410
  await dockerInstall(opts);
8805
9411
  }
8806
9412
  function dockerStatus() {
8807
- const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
9413
+ const r = spawnSync3("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
8808
9414
  encoding: "utf-8",
8809
9415
  timeout: 5e3
8810
9416
  });
@@ -8817,7 +9423,7 @@ function dockerStatus() {
8817
9423
  };
8818
9424
  }
8819
9425
  function readContainerConfig() {
8820
- const r = spawnSync2("docker", ["inspect", "--format", "{{json .Config.Env}}", CONTAINER_NAME], {
9426
+ const r = spawnSync3("docker", ["inspect", "--format", "{{json .Config.Env}}", CONTAINER_NAME], {
8821
9427
  encoding: "utf-8",
8822
9428
  timeout: 5e3
8823
9429
  });
@@ -8871,11 +9477,11 @@ async function dockerSafeStop() {
8871
9477
  console.warn(` \u26A0 Snapshot request failed: ${snapshot.error}. Proceeding with stop.`);
8872
9478
  }
8873
9479
  console.log(" Stopping container (30s grace for CHECKPOINT + WAL flush)...");
8874
- const stop = spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], {
9480
+ const stop = spawnSync3("docker", ["stop", "--timeout=30", CONTAINER_NAME], {
8875
9481
  encoding: "utf-8",
8876
9482
  timeout: 45e3
8877
9483
  });
8878
- const inspect = spawnSync2("docker", ["inspect", "--format", "{{.State.ExitCode}}", CONTAINER_NAME], {
9484
+ const inspect = spawnSync3("docker", ["inspect", "--format", "{{.State.ExitCode}}", CONTAINER_NAME], {
8879
9485
  encoding: "utf-8",
8880
9486
  timeout: 5e3
8881
9487
  });
@@ -8899,7 +9505,7 @@ async function dockerSafeStart() {
8899
9505
  console.log(" Container is already running.");
8900
9506
  return { ok: true, pgdataState: "running" };
8901
9507
  }
8902
- const exists = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
9508
+ const exists = spawnSync3("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
8903
9509
  encoding: "utf-8",
8904
9510
  timeout: 5e3
8905
9511
  });
@@ -8919,7 +9525,7 @@ async function dockerSafeStart() {
8919
9525
  mkdirSync8(PGDATA_PATH, { recursive: true });
8920
9526
  }
8921
9527
  console.log(" Starting container...");
8922
- const start = spawnSync2("docker", ["start", CONTAINER_NAME], {
9528
+ const start = spawnSync3("docker", ["start", CONTAINER_NAME], {
8923
9529
  encoding: "utf-8",
8924
9530
  timeout: 3e4
8925
9531
  });
@@ -8987,6 +9593,56 @@ var init_dockerInstall = __esm({
8987
9593
  }
8988
9594
  });
8989
9595
 
9596
+ // cli/local-cc/setupToken.ts
9597
+ import { spawn as nodeSpawn } from "child_process";
9598
+ import { readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
9599
+ import { homedir as homedir8, platform as platform3 } from "os";
9600
+ import { join as join8 } from "path";
9601
+ function captureClaudeSetupToken() {
9602
+ const tmpFile = join8(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
9603
+ const isMac = platform3() === "darwin";
9604
+ const bin = "script";
9605
+ const args2 = isMac ? ["-q", tmpFile, "claude", "setup-token"] : ["-qec", "claude setup-token", tmpFile];
9606
+ return new Promise((resolve4, reject) => {
9607
+ const proc = nodeSpawn(bin, args2, { stdio: "inherit" });
9608
+ proc.on("error", (err) => reject(new Error(`Failed to spawn claude setup-token: ${err.message}`)));
9609
+ proc.on("close", (code) => {
9610
+ let raw = "";
9611
+ try {
9612
+ raw = readFileSync8(tmpFile, "utf-8");
9613
+ } catch (e) {
9614
+ reject(new Error(`Could not read script output file: ${e.message}`));
9615
+ return;
9616
+ }
9617
+ try {
9618
+ unlinkSync3(tmpFile);
9619
+ } catch {
9620
+ }
9621
+ if (code !== 0) {
9622
+ reject(new Error(`claude setup-token exited with code ${code}`));
9623
+ return;
9624
+ }
9625
+ const yellowRe = /\x1B\[38;2;255;193;7m([^\x1B]*)/g;
9626
+ let yellow = "";
9627
+ let m;
9628
+ while ((m = yellowRe.exec(raw)) !== null) yellow += m[1];
9629
+ const token = yellow.replace(/\s/g, "").match(/sk-ant-oat01-[A-Za-z0-9_-]+/);
9630
+ if (!token) {
9631
+ reject(new Error(`Could not find token in claude setup-token output (file=${raw.length}b, yellow=${yellow.length}b)`));
9632
+ return;
9633
+ }
9634
+ resolve4(token[0]);
9635
+ });
9636
+ });
9637
+ }
9638
+ var SYNKRO_DIR3;
9639
+ var init_setupToken = __esm({
9640
+ "cli/local-cc/setupToken.ts"() {
9641
+ "use strict";
9642
+ SYNKRO_DIR3 = join8(homedir8(), ".synkro");
9643
+ }
9644
+ });
9645
+
8990
9646
  // cli/commands/setupGithub.ts
8991
9647
  var setupGithub_exports = {};
8992
9648
  __export(setupGithub_exports, {
@@ -8995,15 +9651,15 @@ __export(setupGithub_exports, {
8995
9651
  });
8996
9652
  import { createInterface as createInterface2 } from "readline/promises";
8997
9653
  import { stdin as input, stdout as output } from "process";
8998
- import { execSync as execSync5, spawn as nodeSpawn } from "child_process";
8999
- import { existsSync as existsSync10, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
9000
- import { homedir as homedir8, platform as platform3 } from "os";
9001
- import { join as join8 } from "path";
9654
+ import { execSync as execSync5 } from "child_process";
9655
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
9656
+ import { homedir as homedir9, platform as platform4 } from "os";
9657
+ import { join as join9 } from "path";
9002
9658
  import { execFile as execFile2 } from "child_process";
9003
9659
  function readConfig() {
9004
9660
  if (!existsSync10(CONFIG_PATH)) return {};
9005
9661
  const out = {};
9006
- for (const line of readFileSync8(CONFIG_PATH, "utf-8").split("\n")) {
9662
+ for (const line of readFileSync9(CONFIG_PATH, "utf-8").split("\n")) {
9007
9663
  const t = line.trim();
9008
9664
  if (!t || t.startsWith("#")) continue;
9009
9665
  const eq = t.indexOf("=");
@@ -9040,7 +9696,7 @@ async function prompt(rl, q, opts = {}) {
9040
9696
  return await rl.question(q);
9041
9697
  }
9042
9698
  function openBrowser3(url) {
9043
- const os = platform3();
9699
+ const os = platform4();
9044
9700
  let bin;
9045
9701
  let args2;
9046
9702
  switch (os) {
@@ -9063,42 +9719,6 @@ function openBrowser3(url) {
9063
9719
  function sleep(ms) {
9064
9720
  return new Promise((r) => setTimeout(r, ms));
9065
9721
  }
9066
- function captureClaudeSetupToken() {
9067
- const tmpFile = join8(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
9068
- return new Promise((resolve4, reject) => {
9069
- const proc = nodeSpawn("script", ["-q", tmpFile, "claude", "setup-token"], {
9070
- stdio: "inherit"
9071
- });
9072
- proc.on("error", (err) => reject(new Error(`Failed to spawn claude setup-token: ${err.message}`)));
9073
- proc.on("close", (code) => {
9074
- let raw = "";
9075
- try {
9076
- raw = readFileSync8(tmpFile, "utf-8");
9077
- } catch (e) {
9078
- reject(new Error(`Could not read script output file: ${e.message}`));
9079
- return;
9080
- }
9081
- try {
9082
- unlinkSync3(tmpFile);
9083
- } catch {
9084
- }
9085
- if (code !== 0) {
9086
- reject(new Error(`claude setup-token exited with code ${code}`));
9087
- return;
9088
- }
9089
- const yellowRe = /\x1B\[38;2;255;193;7m([^\x1B]*)/g;
9090
- let yellow = "";
9091
- let m;
9092
- while ((m = yellowRe.exec(raw)) !== null) yellow += m[1];
9093
- const token = yellow.replace(/\s/g, "").match(/sk-ant-oat01-[A-Za-z0-9_-]+/);
9094
- if (!token) {
9095
- reject(new Error(`Could not find token in claude setup-token output (file=${raw.length}b, yellow=${yellow.length}b)`));
9096
- return;
9097
- }
9098
- resolve4(token[0]);
9099
- });
9100
- });
9101
- }
9102
9722
  async function apiCall(gatewayUrl, jwt2, path, opts = {}) {
9103
9723
  const resp = await fetch(`${gatewayUrl}${path}`, {
9104
9724
  ...opts,
@@ -9339,14 +9959,15 @@ Will push secrets to ${selected.length} repo(s):`);
9339
9959
  console.log(`Secrets pushed: ${SECRET_NAMES.CLAUDE_OAUTH}, ${SECRET_NAMES.SYNKRO_API_KEY}`);
9340
9960
  console.log("Open a PR on any selected repo to trigger your first Synkro scan.");
9341
9961
  }
9342
- var SYNKRO_DIR3, CONFIG_PATH;
9962
+ var SYNKRO_DIR4, CONFIG_PATH;
9343
9963
  var init_setupGithub = __esm({
9344
9964
  "cli/commands/setupGithub.ts"() {
9345
9965
  "use strict";
9346
9966
  init_githubSetup();
9347
9967
  init_stub();
9348
- SYNKRO_DIR3 = join8(homedir8(), ".synkro");
9349
- CONFIG_PATH = join8(SYNKRO_DIR3, "config.env");
9968
+ init_setupToken();
9969
+ SYNKRO_DIR4 = join9(homedir9(), ".synkro");
9970
+ CONFIG_PATH = join9(SYNKRO_DIR4, "config.env");
9350
9971
  }
9351
9972
  });
9352
9973
 
@@ -9360,16 +9981,16 @@ __export(install_exports, {
9360
9981
  syncSkillFiles: () => syncSkillFiles,
9361
9982
  writeHookScripts: () => writeHookScripts
9362
9983
  });
9363
- import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
9364
- import { homedir as homedir9 } from "os";
9365
- import { join as join9 } from "path";
9984
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync10, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync } from "fs";
9985
+ import { homedir as homedir10 } from "os";
9986
+ import { join as join10 } from "path";
9366
9987
  import { execSync as execSync6 } from "child_process";
9367
9988
  import { createInterface as createInterface3 } from "readline";
9368
9989
  function resolvePersistedHookMode() {
9369
9990
  if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
9370
9991
  if (process.env.SYNKRO_HOOK_MODE === "stub") return "stub";
9371
9992
  try {
9372
- const env = readFileSync9(CONFIG_PATH2, "utf-8");
9993
+ const env = readFileSync10(CONFIG_PATH2, "utf-8");
9373
9994
  if (/^SYNKRO_HOOK_MODE=['"]?full['"]?\s*$/m.test(env)) return "full";
9374
9995
  } catch {
9375
9996
  }
@@ -9389,6 +10010,7 @@ function parseArgs(argv) {
9389
10010
  else if (a === "--no-mcp") opts.noMcp = true;
9390
10011
  else if (a === "--force" || a === "-f") opts.force = true;
9391
10012
  else if (a === "--link-repo") opts.linkRepo = true;
10013
+ else if (a === "--byok-gateway") opts.byokGateway = true;
9392
10014
  else if (a === "--stub" || a === "--mode=stub") opts.hookMode = "stub";
9393
10015
  else if (a === "--legacy" || a === "--mode=full") opts.hookMode = "full";
9394
10016
  }
@@ -9461,25 +10083,12 @@ async function promptCursorApiKey(opts) {
9461
10083
  console.log(" \u26A0 Skipped \u2014 Cursor workers will be idle. Re-run install or pass --cursor-api-key=\u2026 later.");
9462
10084
  }
9463
10085
  }
9464
- async function promptGradingMode() {
9465
- if (!process.stdin.isTTY) return "local";
9466
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
9467
- return new Promise((resolve4) => {
9468
- rl.question(
9469
- "Where should grading run?\n local \u2014 on this machine, via the Synkro container (default)\n byok \u2014 via an LLM API using your own provider key\nChoose [local] / byok: ",
9470
- (answer) => {
9471
- rl.close();
9472
- resolve4(answer.trim().toLowerCase() === "byok" ? "byok" : "local");
9473
- }
9474
- );
9475
- });
9476
- }
9477
- async function promptStorageMode() {
10086
+ async function promptDeployLocation() {
9478
10087
  if (!process.stdin.isTTY) return "local";
9479
10088
  const rl = createInterface3({ input: process.stdin, output: process.stdout });
9480
10089
  return new Promise((resolve4) => {
9481
10090
  rl.question(
9482
- "Where should telemetry be stored?\n local \u2014 on this machine only (default)\n cloud \u2014 sent to Synkro cloud\nChoose [local] / cloud: ",
10091
+ "Where should Synkro run?\n local \u2014 a grading container on this machine (Docker) (default)\n cloud \u2014 a private container hosted by Synkro (Cloudflare)\nBoth use your own Claude key. Choose [local] / cloud: ",
9483
10092
  (answer) => {
9484
10093
  rl.close();
9485
10094
  resolve4(answer.trim().toLowerCase() === "cloud" ? "cloud" : "local");
@@ -9518,36 +10127,36 @@ coding patterns and the dashboard shows full history. (Y/n) `
9518
10127
  }
9519
10128
  }
9520
10129
  function ensureSynkroDir() {
9521
- mkdirSync9(SYNKRO_DIR4, { recursive: true });
10130
+ mkdirSync9(SYNKRO_DIR5, { recursive: true });
9522
10131
  mkdirSync9(HOOKS_DIR, { recursive: true });
9523
10132
  mkdirSync9(BIN_DIR, { recursive: true });
9524
10133
  mkdirSync9(OFFSETS_DIR, { recursive: true });
9525
- mkdirSync9(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
10134
+ mkdirSync9(join10(SYNKRO_DIR5, "sessions"), { recursive: true });
9526
10135
  }
9527
10136
  function writeHookScripts(mode = "stub") {
9528
- const installExtractCorePath = join9(HOOKS_DIR, "installExtractCore.ts");
9529
- const bashScriptPath = join9(HOOKS_DIR, "cc-bash-judge.ts");
9530
- const bashFollowupScriptPath = join9(HOOKS_DIR, "cc-bash-followup.ts");
9531
- const editPrecheckScriptPath = join9(HOOKS_DIR, "cc-edit-precheck.ts");
9532
- const cwePrecheckScriptPath = join9(HOOKS_DIR, "cc-cwe-precheck.ts");
9533
- const cvePrecheckScriptPath = join9(HOOKS_DIR, "cc-cve-precheck.ts");
9534
- const planJudgeScriptPath = join9(HOOKS_DIR, "cc-plan-judge.ts");
9535
- const agentJudgeScriptPath = join9(HOOKS_DIR, "cc-agent-judge.ts");
9536
- const stopSummaryScriptPath = join9(HOOKS_DIR, "cc-stop-summary.ts");
9537
- const sessionStartScriptPath = join9(HOOKS_DIR, "cc-session-start.ts");
9538
- const transcriptSyncScriptPath = join9(HOOKS_DIR, "cc-transcript-sync.ts");
9539
- const userPromptSubmitScriptPath = join9(HOOKS_DIR, "cc-user-prompt-submit.ts");
9540
- const commonScriptPath = join9(HOOKS_DIR, "_synkro-common.ts");
9541
- const commonBashScriptPath = join9(HOOKS_DIR, "_synkro-common.sh");
9542
- const installScanScriptPath = join9(HOOKS_DIR, "cc-install-scan.ts");
9543
- const cursorBashJudgePath = join9(HOOKS_DIR, "cursor-bash-judge.ts");
9544
- const cursorEditCapturePath = join9(HOOKS_DIR, "cursor-edit-capture.ts");
9545
- const cursorAgentCapturePath = join9(HOOKS_DIR, "cursor-agent-capture.ts");
9546
- const mcpStdioProxyPath = join9(HOOKS_DIR, "mcp-stdio-proxy.ts");
9547
- const taskActivateIntentScriptPath = join9(HOOKS_DIR, "cc-task-activate-intent.ts");
9548
- const mcpGateScriptPath = join9(HOOKS_DIR, "cc-mcp-gate.ts");
10137
+ const installExtractCorePath = join10(HOOKS_DIR, "installExtractCore.ts");
10138
+ const bashScriptPath = join10(HOOKS_DIR, "cc-bash-judge.ts");
10139
+ const bashFollowupScriptPath = join10(HOOKS_DIR, "cc-bash-followup.ts");
10140
+ const editPrecheckScriptPath = join10(HOOKS_DIR, "cc-edit-precheck.ts");
10141
+ const cwePrecheckScriptPath = join10(HOOKS_DIR, "cc-cwe-precheck.ts");
10142
+ const cvePrecheckScriptPath = join10(HOOKS_DIR, "cc-cve-precheck.ts");
10143
+ const planJudgeScriptPath = join10(HOOKS_DIR, "cc-plan-judge.ts");
10144
+ const agentJudgeScriptPath = join10(HOOKS_DIR, "cc-agent-judge.ts");
10145
+ const stopSummaryScriptPath = join10(HOOKS_DIR, "cc-stop-summary.ts");
10146
+ const sessionStartScriptPath = join10(HOOKS_DIR, "cc-session-start.ts");
10147
+ const transcriptSyncScriptPath = join10(HOOKS_DIR, "cc-transcript-sync.ts");
10148
+ const userPromptSubmitScriptPath = join10(HOOKS_DIR, "cc-user-prompt-submit.ts");
10149
+ const commonScriptPath = join10(HOOKS_DIR, "_synkro-common.ts");
10150
+ const commonBashScriptPath = join10(HOOKS_DIR, "_synkro-common.sh");
10151
+ const installScanScriptPath = join10(HOOKS_DIR, "cc-install-scan.ts");
10152
+ const cursorBashJudgePath = join10(HOOKS_DIR, "cursor-bash-judge.ts");
10153
+ const cursorEditCapturePath = join10(HOOKS_DIR, "cursor-edit-capture.ts");
10154
+ const cursorAgentCapturePath = join10(HOOKS_DIR, "cursor-agent-capture.ts");
10155
+ const mcpStdioProxyPath = join10(HOOKS_DIR, "mcp-stdio-proxy.ts");
10156
+ const taskActivateIntentScriptPath = join10(HOOKS_DIR, "cc-task-activate-intent.ts");
10157
+ const mcpGateScriptPath = join10(HOOKS_DIR, "cc-mcp-gate.ts");
9549
10158
  if (mode === "stub") {
9550
- const stubCommonPath = join9(HOOKS_DIR, "_synkro-stub-common.ts");
10159
+ const stubCommonPath = join10(HOOKS_DIR, "_synkro-stub-common.ts");
9551
10160
  const stubFiles = [
9552
10161
  [stubCommonPath, STUB_COMMON_TS],
9553
10162
  [bashScriptPath, STUB_BASH_JUDGE_TS],
@@ -9576,7 +10185,7 @@ function writeHookScripts(mode = "stub") {
9576
10185
  chmodSync2(mcpStdioProxyPath, 493);
9577
10186
  for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
9578
10187
  try {
9579
- unlinkSync4(join9(HOOKS_DIR, stale));
10188
+ unlinkSync4(join10(HOOKS_DIR, stale));
9580
10189
  } catch {
9581
10190
  }
9582
10191
  }
@@ -9643,7 +10252,7 @@ function writeHookScripts(mode = "stub") {
9643
10252
  chmodSync2(taskActivateIntentScriptPath, 493);
9644
10253
  chmodSync2(mcpGateScriptPath, 493);
9645
10254
  try {
9646
- unlinkSync4(join9(HOOKS_DIR, "_synkro-stub-common.ts"));
10255
+ unlinkSync4(join10(HOOKS_DIR, "_synkro-stub-common.ts"));
9647
10256
  } catch {
9648
10257
  }
9649
10258
  return {
@@ -9679,7 +10288,7 @@ function resolveSynkroBundle() {
9679
10288
  return null;
9680
10289
  }
9681
10290
  function writeConfigEnv(opts) {
9682
- const credsPath = join9(SYNKRO_DIR4, "credentials.json");
10291
+ const credsPath = join10(SYNKRO_DIR5, "credentials.json");
9683
10292
  const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
9684
10293
  const safeUserId = sanitizeConfigValue(opts.userId);
9685
10294
  const safeOrgId = sanitizeConfigValue(opts.orgId);
@@ -9695,7 +10304,7 @@ function writeConfigEnv(opts) {
9695
10304
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
9696
10305
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
9697
10306
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
9698
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.57")}`
10307
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.59")}`
9699
10308
  ];
9700
10309
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
9701
10310
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -9709,17 +10318,66 @@ function writeConfigEnv(opts) {
9709
10318
  lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
9710
10319
  lines.push(`SYNKRO_GRADING_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.gradingMode ?? "local", 16))}`);
9711
10320
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
10321
+ lines.push(`SYNKRO_DEPLOY_LOCATION=${shellQuoteSingle(sanitizeConfigValue(opts.deployLocation ?? "local", 16))}`);
9712
10322
  lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "stub", 8))}`);
9713
10323
  lines.push("");
9714
10324
  writeFileSync8(CONFIG_PATH2, lines.join("\n"), "utf-8");
9715
10325
  chmodSync2(CONFIG_PATH2, 384);
9716
10326
  }
10327
+ async function provisionCloudContainer(opts) {
10328
+ let setupToken;
10329
+ try {
10330
+ console.log(" Authorize your Claude key for the hosted Synkro worker \u2014");
10331
+ console.log(" a browser window will open for approval...\n");
10332
+ setupToken = await captureClaudeSetupToken();
10333
+ } catch (err) {
10334
+ console.error(` \u2717 Could not capture Claude setup-token: ${err.message}`);
10335
+ console.error(" Cloud needs a Claude setup-token. Run `claude setup-token` manually, then re-run install.");
10336
+ process.exit(1);
10337
+ }
10338
+ const repo = detectGitRepo2() || void 0;
10339
+ const harness = [];
10340
+ if (opts.hasClaudeCode) harness.push("claude-code");
10341
+ if (opts.hasCursor) harness.push("cursor");
10342
+ const total = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
10343
+ try {
10344
+ const resp = await fetch(`${opts.gatewayUrl}/api/v1/cli/cloud-provision`, {
10345
+ method: "POST",
10346
+ headers: { "Authorization": `Bearer ${opts.jwt}`, "Content-Type": "application/json" },
10347
+ body: JSON.stringify({
10348
+ org_id: opts.orgId,
10349
+ user_id: opts.userId,
10350
+ email: opts.email,
10351
+ harness,
10352
+ claude_workers: total,
10353
+ cursor_workers: 0,
10354
+ connected_repo: repo,
10355
+ ruleset: readFullSynkroFile()?.ruleset || "default",
10356
+ setup_token: setupToken
10357
+ // never logged; gateway stores it as the org secret
10358
+ })
10359
+ });
10360
+ if (!resp.ok) {
10361
+ const t = await resp.text().catch(() => "");
10362
+ throw new Error(`cloud-provision failed (${resp.status}): ${t.slice(0, 200)}`);
10363
+ }
10364
+ const out = await resp.json().catch(() => ({}));
10365
+ console.log(` \u2713 cloud container provisioned${out.container_id ? ` (${out.container_id})` : ""}`);
10366
+ console.log();
10367
+ } catch (err) {
10368
+ console.error(` \u2717 Cloud provisioning failed: ${err.message}`);
10369
+ console.error(" Hooks + MCP are installed; re-run `synkro install` \u2192 cloud to retry.");
10370
+ console.log();
10371
+ } finally {
10372
+ setupToken = "";
10373
+ }
10374
+ }
9717
10375
  function resolveDeploymentMode() {
9718
10376
  const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE?.toLowerCase();
9719
10377
  if (envOverride === "bare-host" || envOverride === "docker") return envOverride;
9720
10378
  try {
9721
10379
  if (existsSync11(CONFIG_PATH2)) {
9722
- const m = readFileSync9(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
10380
+ const m = readFileSync10(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
9723
10381
  const val = m?.[1]?.toLowerCase();
9724
10382
  if (val === "bare-host" || val === "docker") return val;
9725
10383
  }
@@ -9746,16 +10404,16 @@ function collectLocalMetadata(includeClaudeCode = true) {
9746
10404
  meta.cc_version = execSync6("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
9747
10405
  } catch {
9748
10406
  }
9749
- const claudeDir = join9(homedir9(), ".claude");
10407
+ const claudeDir = join10(homedir10(), ".claude");
9750
10408
  try {
9751
- const settings = JSON.parse(readFileSync9(join9(claudeDir, "settings.json"), "utf-8"));
10409
+ const settings = JSON.parse(readFileSync10(join10(claudeDir, "settings.json"), "utf-8"));
9752
10410
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
9753
10411
  if (plugins.length) meta.enabled_plugins = plugins;
9754
10412
  if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
9755
10413
  } catch {
9756
10414
  }
9757
10415
  try {
9758
- const mcpCache = JSON.parse(readFileSync9(join9(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
10416
+ const mcpCache = JSON.parse(readFileSync10(join10(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
9759
10417
  const mcpNames = Object.keys(mcpCache);
9760
10418
  if (mcpNames.length) meta.mcp_servers = mcpNames;
9761
10419
  } catch {
@@ -9767,10 +10425,10 @@ function collectLocalMetadata(includeClaudeCode = true) {
9767
10425
  } catch {
9768
10426
  }
9769
10427
  try {
9770
- const sessionsDir = join9(claudeDir, "sessions");
10428
+ const sessionsDir = join10(claudeDir, "sessions");
9771
10429
  const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
9772
10430
  for (const f of files) {
9773
- const s = JSON.parse(readFileSync9(join9(sessionsDir, f), "utf-8"));
10431
+ const s = JSON.parse(readFileSync10(join10(sessionsDir, f), "utf-8"));
9774
10432
  if (s.version) {
9775
10433
  meta.cc_version = meta.cc_version || s.version;
9776
10434
  break;
@@ -9878,6 +10536,7 @@ async function installCommand(opts = {}) {
9878
10536
  let agents;
9879
10537
  let gradingMode;
9880
10538
  let storageMode;
10539
+ let deployLocation = "local";
9881
10540
  let transcriptCC = false;
9882
10541
  let transcriptCursor = false;
9883
10542
  if (existingSynkro) {
@@ -9887,8 +10546,9 @@ async function installCommand(opts = {}) {
9887
10546
  (a) => a.kind === "claude_code" && wantCC || a.kind === "cursor" && wantCursor
9888
10547
  );
9889
10548
  if (agents.length === 0 && detected.length > 0) agents = detected;
10549
+ deployLocation = existingSynkro.grader.location === "cloud" ? "cloud" : "local";
9890
10550
  gradingMode = existingSynkro.grader.mode === "byok" ? "byok" : "local";
9891
- storageMode = "local";
10551
+ storageMode = deployLocation === "cloud" ? "cloud" : "local";
9892
10552
  console.log(`Using .synkro config:`);
9893
10553
  for (const w of existingSynkro.warnings) console.warn(` \u26A0 ${w}`);
9894
10554
  console.log(` harness: ${existingSynkro.harness.join(", ")}`);
@@ -9912,15 +10572,20 @@ async function installCommand(opts = {}) {
9912
10572
  console.log(`Installing hooks for: ${agents.map((a) => a.name).join(", ")}
9913
10573
  `);
9914
10574
  }
9915
- gradingMode = await promptGradingMode();
9916
- storageMode = await promptStorageMode();
9917
- console.log(` grading: ${gradingMode} storage: ${storageMode}
10575
+ if (opts.byokGateway) {
10576
+ gradingMode = "byok";
10577
+ storageMode = "cloud";
10578
+ deployLocation = "local";
10579
+ console.log(" deploy: legacy BYOK-gateway grading (no container)\n");
10580
+ } else {
10581
+ deployLocation = await promptDeployLocation();
10582
+ gradingMode = "local";
10583
+ storageMode = deployLocation === "cloud" ? "cloud" : "local";
10584
+ console.log(` deploy: ${deployLocation}
9918
10585
  `);
9919
- if (gradingMode === "byok") {
9920
- console.log(" BYOK grading uses your own provider key \u2014 register one in the");
9921
- console.log(" dashboard under Settings \u2192 Provider Keys if you have not already.\n");
9922
10586
  }
9923
10587
  }
10588
+ const target = deployLocation === "cloud" ? "cloud-container" : opts.byokGateway || gradingMode === "byok" ? "gateway-byok" : "local-docker";
9924
10589
  {
9925
10590
  const wantCC = agents.some((a) => a.kind === "claude_code");
9926
10591
  const wantCursor = agents.some((a) => a.kind === "cursor");
@@ -9945,9 +10610,9 @@ async function installCommand(opts = {}) {
9945
10610
  const scripts = writeHookScripts(hookMode);
9946
10611
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
9947
10612
  for (const mode of ["edit", "bash"]) {
9948
- const pidFile = join9(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
10613
+ const pidFile = join10(SYNKRO_DIR5, "daemon", mode, "daemon.pid");
9949
10614
  try {
9950
- const pid = parseInt(readFileSync9(pidFile, "utf-8").trim(), 10);
10615
+ const pid = parseInt(readFileSync10(pidFile, "utf-8").trim(), 10);
9951
10616
  if (pid > 0) {
9952
10617
  process.kill(pid, "SIGTERM");
9953
10618
  console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
@@ -10012,10 +10677,11 @@ async function installCommand(opts = {}) {
10012
10677
  } catch {
10013
10678
  }
10014
10679
  const profile = await fetchUserProfile(gatewayUrl, token, hasClaudeCode);
10015
- const cloudOnly = gradingMode === "byok" && storageMode === "cloud";
10016
- const useLocalMcp = !cloudOnly;
10017
- if (cloudOnly) {
10018
- console.log("Cloud-only setup (BYOK grading + cloud storage) \u2014 skipping the local container.\n");
10680
+ const useLocalMcp = target === "local-docker";
10681
+ if (target === "cloud-container") {
10682
+ console.log("Cloud setup \u2014 Synkro will provision a private hosted container for your org.\n");
10683
+ } else if (target === "gateway-byok") {
10684
+ console.log("Cloud-only setup (BYOK grading via gateway) \u2014 skipping the local container.\n");
10019
10685
  }
10020
10686
  if (hasClaudeCode && !opts.noMcp) {
10021
10687
  if (useLocalMcp) {
@@ -10029,7 +10695,7 @@ async function installCommand(opts = {}) {
10029
10695
  if (mintResp.ok) {
10030
10696
  const minted = await mintResp.json();
10031
10697
  mcpJwt = minted.token;
10032
- writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
10698
+ writeFileSync8(join10(SYNKRO_DIR5, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
10033
10699
  } else {
10034
10700
  console.warn(" \u26A0 Could not mint MCP token \u2014 local server will reject requests until re-installed.");
10035
10701
  }
@@ -10057,7 +10723,7 @@ async function installCommand(opts = {}) {
10057
10723
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
10058
10724
  }
10059
10725
  const minted = await mintResp.json();
10060
- writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
10726
+ writeFileSync8(join10(SYNKRO_DIR5, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
10061
10727
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
10062
10728
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
10063
10729
  console.log(` url: ${mcp.url}`);
@@ -10074,7 +10740,7 @@ async function installCommand(opts = {}) {
10074
10740
  if (hasCursor && !opts.noMcp) {
10075
10741
  try {
10076
10742
  if (useLocalMcp) {
10077
- const jwtPath = join9(SYNKRO_DIR4, ".mcp-jwt");
10743
+ const jwtPath = join10(SYNKRO_DIR5, ".mcp-jwt");
10078
10744
  if (!existsSync11(jwtPath)) {
10079
10745
  const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
10080
10746
  method: "POST",
@@ -10103,7 +10769,7 @@ async function installCommand(opts = {}) {
10103
10769
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
10104
10770
  }
10105
10771
  const minted = await mintResp.json();
10106
- writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
10772
+ writeFileSync8(join10(SYNKRO_DIR5, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
10107
10773
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: minted.token });
10108
10774
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
10109
10775
  console.log(` url: ${mcp.url}`);
@@ -10116,7 +10782,7 @@ async function installCommand(opts = {}) {
10116
10782
  }
10117
10783
  const synkroBundle = resolveSynkroBundle();
10118
10784
  const persistedMode = resolveDeploymentMode();
10119
- writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode, hookMode });
10785
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode, deployLocation, hookMode });
10120
10786
  console.log(`Wrote config to ${CONFIG_PATH2}`);
10121
10787
  console.log(` inference: ${profile.inference} (server-side grading)`);
10122
10788
  if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
@@ -10128,7 +10794,7 @@ async function installCommand(opts = {}) {
10128
10794
  } catch (err) {
10129
10795
  console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
10130
10796
  }
10131
- writeSynkroFileIfMissing({ hasClaudeCode, hasCursor, gradingMode });
10797
+ writeSynkroFileIfMissing({ hasClaudeCode, hasCursor, gradingMode, deployLocation });
10132
10798
  console.log();
10133
10799
  if (useLocalMcp) {
10134
10800
  const { assertDockerAvailable: assertDockerAvailable2 } = await Promise.resolve().then(() => (init_dockerInstall(), dockerInstall_exports));
@@ -10179,7 +10845,7 @@ async function installCommand(opts = {}) {
10179
10845
  const ready = await waitForContainerReady(6e4);
10180
10846
  if (ready) {
10181
10847
  console.log(" \u2713 container ready");
10182
- const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
10848
+ const mcpJwt = readFileSync10(join10(SYNKRO_DIR5, ".mcp-jwt"), "utf-8").trim();
10183
10849
  try {
10184
10850
  const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
10185
10851
  method: "POST",
@@ -10209,6 +10875,8 @@ async function installCommand(opts = {}) {
10209
10875
  process.exit(1);
10210
10876
  }
10211
10877
  console.log();
10878
+ } else if (target === "cloud-container") {
10879
+ await provisionCloudContainer({ gatewayUrl, jwt: token, orgId, userId, email, hasClaudeCode, hasCursor });
10212
10880
  }
10213
10881
  if (transcriptConsent) {
10214
10882
  const repo = detectGitRepo2();
@@ -10217,7 +10885,7 @@ async function installCommand(opts = {}) {
10217
10885
  try {
10218
10886
  let mcpToken = "";
10219
10887
  try {
10220
- mcpToken = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
10888
+ mcpToken = readFileSync10(join10(SYNKRO_DIR5, ".mcp-jwt"), "utf-8").trim();
10221
10889
  } catch {
10222
10890
  }
10223
10891
  if (mcpToken) {
@@ -10345,11 +11013,11 @@ function writeSynkroFileIfMissing(opts) {
10345
11013
  try {
10346
11014
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10347
11015
  if (!root) return;
10348
- if (root === homedir9()) return;
10349
- const fp = join9(root, "synkro.toml");
11016
+ if (root === homedir10()) return;
11017
+ const fp = join10(root, "synkro.toml");
10350
11018
  let hasFile = false;
10351
11019
  try {
10352
- hasFile = statSync2(fp).isFile();
11020
+ hasFile = statSync(fp).isFile();
10353
11021
  } catch {
10354
11022
  }
10355
11023
  if (hasFile) {
@@ -10377,6 +11045,7 @@ function writeSynkroFileIfMissing(opts) {
10377
11045
  "skills = []",
10378
11046
  "",
10379
11047
  "[grader]",
11048
+ `location = "${opts.deployLocation || "local"}"`,
10380
11049
  `pool = "${pool}"`,
10381
11050
  `mode = "${mode}"`,
10382
11051
  "",
@@ -10394,9 +11063,9 @@ function readFullSynkroFile() {
10394
11063
  try {
10395
11064
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10396
11065
  if (!root) return null;
10397
- const fp = join9(root, "synkro.toml");
11066
+ const fp = join10(root, "synkro.toml");
10398
11067
  if (!existsSync11(fp)) return null;
10399
- const parsed = parseSynkroToml2(readFileSync9(fp, "utf-8"));
11068
+ const parsed = parseSynkroToml2(readFileSync10(fp, "utf-8"));
10400
11069
  const valid = ["claude-code", "cursor"];
10401
11070
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
10402
11071
  const resolved = resolveGraderPool(parsed);
@@ -10404,7 +11073,8 @@ function readFullSynkroFile() {
10404
11073
  harness: harness.length > 0 ? harness : ["claude-code", "cursor"],
10405
11074
  grader: {
10406
11075
  pool: resolved.pool,
10407
- mode: ["local", "byok"].includes(parsed.grader?.mode) ? parsed.grader.mode : "local"
11076
+ mode: ["local", "byok"].includes(parsed.grader?.mode) ? parsed.grader.mode : "local",
11077
+ location: parsed.grader?.location === "cloud" ? "cloud" : "local"
10408
11078
  },
10409
11079
  workers: {
10410
11080
  ...resolved.claudeWorkers != null ? { claude: resolved.claudeWorkers } : {},
@@ -10432,7 +11102,7 @@ function reconcileHarness() {
10432
11102
  console.log(`synkro.toml: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
10433
11103
  const scripts = writeHookScripts(resolvePersistedHookMode());
10434
11104
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
10435
- const ccSettings = join9(homedir9(), ".claude", "settings.json");
11105
+ const ccSettings = join10(homedir10(), ".claude", "settings.json");
10436
11106
  if (wantCC) {
10437
11107
  installCCHooks(ccSettings, {
10438
11108
  bashJudgeScriptPath: scripts.bashScript,
@@ -10452,7 +11122,7 @@ function reconcileHarness() {
10452
11122
  });
10453
11123
  console.log(" \u2713 Claude Code hooks registered");
10454
11124
  try {
10455
- const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
11125
+ const mcpJwt = readFileSync10(join10(SYNKRO_DIR5, ".mcp-jwt"), "utf-8").trim();
10456
11126
  if (mcpJwt) {
10457
11127
  installMcpConfig({ gatewayUrl: "", bearerToken: mcpJwt, local: true });
10458
11128
  console.log(" \u2713 Claude Code MCP registered");
@@ -10462,8 +11132,9 @@ function reconcileHarness() {
10462
11132
  } else {
10463
11133
  if (uninstallCCHooks(ccSettings)) console.log(" \u2717 Claude Code hooks removed");
10464
11134
  if (uninstallMcpConfig()) console.log(" \u2717 Claude Code MCP removed");
11135
+ if (uninstallClaudeDesktopMcpConfig()) console.log(" \u2717 Claude Desktop MCP removed");
10465
11136
  }
10466
- const cursorHooks = join9(homedir9(), ".cursor", "hooks.json");
11137
+ const cursorHooks = join10(homedir10(), ".cursor", "hooks.json");
10467
11138
  if (wantCursor) {
10468
11139
  installCursorHooks(cursorHooks, {
10469
11140
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
@@ -10517,7 +11188,7 @@ async function syncSkillFiles() {
10517
11188
  if (resolved.length === 0) return;
10518
11189
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
10519
11190
  const tasks = resolved.map((fp) => {
10520
- const content = readFileSync9(fp, "utf-8");
11191
+ const content = readFileSync10(fp, "utf-8");
10521
11192
  const source = `skill:${fp.split("/").pop()}`;
10522
11193
  if (!content.trim()) {
10523
11194
  console.log(` \u2298 skill ${source}: empty file, skipped`);
@@ -10563,7 +11234,7 @@ function detectGitRepo2() {
10563
11234
  function getClaudeProjectsFolder() {
10564
11235
  const cwd = process.cwd();
10565
11236
  const sanitized = "-" + cwd.replace(/\//g, "-");
10566
- const projectsDir = join9(homedir9(), ".claude", "projects", sanitized);
11237
+ const projectsDir = join10(homedir10(), ".claude", "projects", sanitized);
10567
11238
  return existsSync11(projectsDir) ? projectsDir : null;
10568
11239
  }
10569
11240
  function extractSessionInsights(projectsDir) {
@@ -10571,9 +11242,9 @@ function extractSessionInsights(projectsDir) {
10571
11242
  const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
10572
11243
  for (const file of files) {
10573
11244
  const sessionId = file.replace(".jsonl", "");
10574
- const filePath = join9(projectsDir, file);
11245
+ const filePath = join10(projectsDir, file);
10575
11246
  try {
10576
- const content = readFileSync9(filePath, "utf-8");
11247
+ const content = readFileSync10(filePath, "utf-8");
10577
11248
  const lines = content.split("\n").filter(Boolean);
10578
11249
  for (let i = 0; i < lines.length; i++) {
10579
11250
  try {
@@ -10652,14 +11323,14 @@ function cursorProjectSlug(workspaceRoot) {
10652
11323
  return workspaceRoot.replace(/^[/]+/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10653
11324
  }
10654
11325
  function getCursorTranscriptsDir() {
10655
- const dir = join9(homedir9(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
11326
+ const dir = join10(homedir10(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
10656
11327
  return existsSync11(dir) ? dir : null;
10657
11328
  }
10658
11329
  function isSafeConvId(id) {
10659
11330
  return /^[A-Za-z0-9_-]+$/.test(id);
10660
11331
  }
10661
11332
  function parseCursorTranscriptFile(filePath) {
10662
- const content = readFileSync9(filePath, "utf-8");
11333
+ const content = readFileSync10(filePath, "utf-8");
10663
11334
  const lines = content.split("\n").filter(Boolean);
10664
11335
  const messages = [];
10665
11336
  for (let i = 0; i < lines.length; i++) {
@@ -10691,7 +11362,7 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
10691
11362
  for (let i = 0; i < convDirs.length; i++) {
10692
11363
  const convId = convDirs[i];
10693
11364
  if (!isSafeConvId(convId)) continue;
10694
- const filePath = join9(dir, convId, `${convId}.jsonl`);
11365
+ const filePath = join10(dir, convId, `${convId}.jsonl`);
10695
11366
  if (!existsSync11(filePath)) continue;
10696
11367
  try {
10697
11368
  const all = parseCursorTranscriptFile(filePath);
@@ -10714,8 +11385,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
10714
11385
  process.stdout.write(`\r Progress: ${i + 1}/${convDirs.length} sessions (${totalMessages} messages embedded) `);
10715
11386
  }
10716
11387
  try {
10717
- const lc = readFileSync9(filePath, "utf-8").split("\n").filter(Boolean).length;
10718
- writeFileSync8(join9(OFFSETS_DIR, convId), String(lc), "utf-8");
11388
+ const lc = readFileSync10(filePath, "utf-8").split("\n").filter(Boolean).length;
11389
+ writeFileSync8(join10(OFFSETS_DIR, convId), String(lc), "utf-8");
10719
11390
  } catch {
10720
11391
  }
10721
11392
  }
@@ -10723,7 +11394,7 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
10723
11394
  return { sessions: totalSessions, messages: totalMessages };
10724
11395
  }
10725
11396
  function parseTranscriptFile(filePath) {
10726
- const content = readFileSync9(filePath, "utf-8");
11397
+ const content = readFileSync10(filePath, "utf-8");
10727
11398
  const lines = content.split("\n").filter(Boolean);
10728
11399
  const messages = [];
10729
11400
  for (let i = 0; i < lines.length; i++) {
@@ -10771,7 +11442,7 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
10771
11442
  for (let i = 0; i < files.length; i++) {
10772
11443
  const file = files[i];
10773
11444
  const sessionId = file.replace(".jsonl", "");
10774
- const filePath = join9(projectsDir, file);
11445
+ const filePath = join10(projectsDir, file);
10775
11446
  try {
10776
11447
  const allMessages = parseTranscriptFile(filePath);
10777
11448
  const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
@@ -10793,9 +11464,9 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
10793
11464
  process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
10794
11465
  }
10795
11466
  try {
10796
- const content = readFileSync9(join9(projectsDir, file), "utf-8");
11467
+ const content = readFileSync10(join10(projectsDir, file), "utf-8");
10797
11468
  const lineCount = content.split("\n").filter(Boolean).length;
10798
- writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
11469
+ writeFileSync8(join10(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10799
11470
  } catch {
10800
11471
  }
10801
11472
  }
@@ -10816,7 +11487,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
10816
11487
  const sessions = [];
10817
11488
  for (const file of batch) {
10818
11489
  const sessionId = file.replace(".jsonl", "");
10819
- const filePath = join9(projectsDir, file);
11490
+ const filePath = join10(projectsDir, file);
10820
11491
  try {
10821
11492
  const allMessages = parseTranscriptFile(filePath);
10822
11493
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -10845,18 +11516,18 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
10845
11516
  }
10846
11517
  for (const file of batch) {
10847
11518
  const sessionId = file.replace(".jsonl", "");
10848
- const filePath = join9(projectsDir, file);
11519
+ const filePath = join10(projectsDir, file);
10849
11520
  try {
10850
- const content = readFileSync9(filePath, "utf-8");
11521
+ const content = readFileSync10(filePath, "utf-8");
10851
11522
  const lineCount = content.split("\n").filter(Boolean).length;
10852
- writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
11523
+ writeFileSync8(join10(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10853
11524
  } catch {
10854
11525
  }
10855
11526
  }
10856
11527
  }
10857
11528
  return { sessions: totalSessions, messages: totalMessages };
10858
11529
  }
10859
- var SYNKRO_DIR4, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, MCP_STDIO_PROXY_SRC, OFFSETS_DIR;
11530
+ var SYNKRO_DIR5, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, MCP_STDIO_PROXY_SRC, OFFSETS_DIR;
10860
11531
  var init_install = __esm({
10861
11532
  "cli/commands/install.ts"() {
10862
11533
  "use strict";
@@ -10874,10 +11545,11 @@ var init_install = __esm({
10874
11545
  init_promptFetcher();
10875
11546
  init_claudeDesktopTap();
10876
11547
  init_dockerInstall();
10877
- SYNKRO_DIR4 = join9(homedir9(), ".synkro");
10878
- HOOKS_DIR = join9(SYNKRO_DIR4, "hooks");
10879
- BIN_DIR = join9(SYNKRO_DIR4, "bin");
10880
- CONFIG_PATH2 = join9(SYNKRO_DIR4, "config.env");
11548
+ init_setupToken();
11549
+ SYNKRO_DIR5 = join10(homedir10(), ".synkro");
11550
+ HOOKS_DIR = join10(SYNKRO_DIR5, "hooks");
11551
+ BIN_DIR = join10(SYNKRO_DIR5, "bin");
11552
+ CONFIG_PATH2 = join10(SYNKRO_DIR5, "config.env");
10881
11553
  MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
10882
11554
  import { readFileSync } from 'node:fs';
10883
11555
  import { homedir } from 'node:os';
@@ -10988,15 +11660,15 @@ rl.on('line', async (line) => {
10988
11660
  }
10989
11661
  });
10990
11662
  `;
10991
- OFFSETS_DIR = join9(SYNKRO_DIR4, ".transcript-offsets");
11663
+ OFFSETS_DIR = join10(SYNKRO_DIR5, ".transcript-offsets");
10992
11664
  }
10993
11665
  });
10994
11666
 
10995
11667
  // cli/local-cc/install.ts
10996
- import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10997
- import { join as join10 } from "path";
10998
- import { homedir as homedir10 } from "os";
10999
- import { spawnSync as spawnSync4 } from "child_process";
11668
+ import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync11, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
11669
+ import { join as join11 } from "path";
11670
+ import { homedir as homedir11 } from "os";
11671
+ import { spawnSync as spawnSync5 } from "child_process";
11000
11672
  function writePluginFiles() {
11001
11673
  for (const c of CHANNELS) {
11002
11674
  mkdirSync10(c.sessionDir, { recursive: true });
@@ -11016,7 +11688,7 @@ function writePluginFiles() {
11016
11688
  }
11017
11689
  function runBunInstall() {
11018
11690
  for (const c of CHANNELS) {
11019
- const r = spawnSync4("bun", ["install", "--silent"], {
11691
+ const r = spawnSync5("bun", ["install", "--silent"], {
11020
11692
  cwd: c.sessionDir,
11021
11693
  encoding: "utf-8",
11022
11694
  timeout: 12e4
@@ -11032,7 +11704,7 @@ function safelyMutateClaudeJson(mutator) {
11032
11704
  if (!existsSync12(CLAUDE_JSON_PATH)) {
11033
11705
  return;
11034
11706
  }
11035
- const originalText = readFileSync10(CLAUDE_JSON_PATH, "utf-8");
11707
+ const originalText = readFileSync11(CLAUDE_JSON_PATH, "utf-8");
11036
11708
  let parsed;
11037
11709
  try {
11038
11710
  parsed = JSON.parse(originalText);
@@ -11132,15 +11804,15 @@ function patchClaudeJson() {
11132
11804
  });
11133
11805
  }
11134
11806
  function installLocalCC() {
11135
- let bunCheck = spawnSync4("bun", ["--version"], { encoding: "utf-8" });
11807
+ let bunCheck = spawnSync5("bun", ["--version"], { encoding: "utf-8" });
11136
11808
  if (bunCheck.status !== 0) {
11137
11809
  if (process.platform === "darwin") {
11138
11810
  console.log(" Installing bun via brew...");
11139
- const brewR = spawnSync4("brew", ["install", "oven-sh/bun/bun"], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
11811
+ const brewR = spawnSync5("brew", ["install", "oven-sh/bun/bun"], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
11140
11812
  if (brewR.status !== 0) {
11141
11813
  throw new LocalCCInstallError("bun auto-install failed. Install manually: curl -fsSL https://bun.sh/install | bash");
11142
11814
  }
11143
- bunCheck = spawnSync4("bun", ["--version"], { encoding: "utf-8" });
11815
+ bunCheck = spawnSync5("bun", ["--version"], { encoding: "utf-8" });
11144
11816
  if (bunCheck.status !== 0) {
11145
11817
  throw new LocalCCInstallError("bun installed but not found on PATH. Restart your terminal and re-run install.");
11146
11818
  }
@@ -11174,42 +11846,42 @@ var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_S
11174
11846
  var init_install2 = __esm({
11175
11847
  "cli/local-cc/install.ts"() {
11176
11848
  "use strict";
11177
- CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
11178
- SESSION_DIR = join10(homedir10(), ".synkro", "cc_sessions");
11179
- PLUGIN_PATH = join10(SESSION_DIR, "synkro-channel.ts");
11180
- PLUGIN_PKG_PATH = join10(SESSION_DIR, "package.json");
11181
- PLUGIN_SETTINGS_DIR = join10(SESSION_DIR, ".claude");
11182
- PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
11183
- PROJECT_MCP_PATH = join10(SESSION_DIR, ".mcp.json");
11184
- CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
11185
- RUN_SCRIPT_PATH = join10(SESSION_DIR, "run-claude.sh");
11849
+ CLAUDE_JSON_BACKUP_PATH = join11(homedir11(), ".claude.json.synkro-bak");
11850
+ SESSION_DIR = join11(homedir11(), ".synkro", "cc_sessions");
11851
+ PLUGIN_PATH = join11(SESSION_DIR, "synkro-channel.ts");
11852
+ PLUGIN_PKG_PATH = join11(SESSION_DIR, "package.json");
11853
+ PLUGIN_SETTINGS_DIR = join11(SESSION_DIR, ".claude");
11854
+ PLUGIN_SETTINGS_PATH = join11(PLUGIN_SETTINGS_DIR, "settings.json");
11855
+ PROJECT_MCP_PATH = join11(SESSION_DIR, ".mcp.json");
11856
+ CLAUDE_JSON_PATH = join11(homedir11(), ".claude.json");
11857
+ RUN_SCRIPT_PATH = join11(SESSION_DIR, "run-claude.sh");
11186
11858
  TMUX_SESSION_NAME = "synkro-local-cc";
11187
11859
  CHANNEL_1_PORT = 8941;
11188
- SESSION_DIR_2 = join10(homedir10(), ".synkro", "cc_sessions_2");
11189
- PLUGIN_PATH_2 = join10(SESSION_DIR_2, "synkro-channel.ts");
11190
- PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_2, "package.json");
11191
- PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_2, ".claude");
11192
- PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
11193
- PROJECT_MCP_PATH_2 = join10(SESSION_DIR_2, ".mcp.json");
11194
- RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_2, "run-claude.sh");
11860
+ SESSION_DIR_2 = join11(homedir11(), ".synkro", "cc_sessions_2");
11861
+ PLUGIN_PATH_2 = join11(SESSION_DIR_2, "synkro-channel.ts");
11862
+ PLUGIN_PKG_PATH_2 = join11(SESSION_DIR_2, "package.json");
11863
+ PLUGIN_SETTINGS_DIR_2 = join11(SESSION_DIR_2, ".claude");
11864
+ PLUGIN_SETTINGS_PATH_2 = join11(PLUGIN_SETTINGS_DIR_2, "settings.json");
11865
+ PROJECT_MCP_PATH_2 = join11(SESSION_DIR_2, ".mcp.json");
11866
+ RUN_SCRIPT_PATH_2 = join11(SESSION_DIR_2, "run-claude.sh");
11195
11867
  TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
11196
11868
  CHANNEL_2_PORT = 8951;
11197
- SESSION_DIR_3 = join10(homedir10(), ".synkro", "cc_sessions_3");
11198
- PLUGIN_PATH_3 = join10(SESSION_DIR_3, "synkro-channel.ts");
11199
- PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_3, "package.json");
11200
- PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_3, ".claude");
11201
- PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
11202
- PROJECT_MCP_PATH_3 = join10(SESSION_DIR_3, ".mcp.json");
11203
- RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_3, "run-claude.sh");
11869
+ SESSION_DIR_3 = join11(homedir11(), ".synkro", "cc_sessions_3");
11870
+ PLUGIN_PATH_3 = join11(SESSION_DIR_3, "synkro-channel.ts");
11871
+ PLUGIN_PKG_PATH_3 = join11(SESSION_DIR_3, "package.json");
11872
+ PLUGIN_SETTINGS_DIR_3 = join11(SESSION_DIR_3, ".claude");
11873
+ PLUGIN_SETTINGS_PATH_3 = join11(PLUGIN_SETTINGS_DIR_3, "settings.json");
11874
+ PROJECT_MCP_PATH_3 = join11(SESSION_DIR_3, ".mcp.json");
11875
+ RUN_SCRIPT_PATH_3 = join11(SESSION_DIR_3, "run-claude.sh");
11204
11876
  TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
11205
11877
  CHANNEL_3_PORT = 8942;
11206
- SESSION_DIR_4 = join10(homedir10(), ".synkro", "cc_sessions_4");
11207
- PLUGIN_PATH_4 = join10(SESSION_DIR_4, "synkro-channel.ts");
11208
- PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_4, "package.json");
11209
- PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_4, ".claude");
11210
- PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
11211
- PROJECT_MCP_PATH_4 = join10(SESSION_DIR_4, ".mcp.json");
11212
- RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_4, "run-claude.sh");
11878
+ SESSION_DIR_4 = join11(homedir11(), ".synkro", "cc_sessions_4");
11879
+ PLUGIN_PATH_4 = join11(SESSION_DIR_4, "synkro-channel.ts");
11880
+ PLUGIN_PKG_PATH_4 = join11(SESSION_DIR_4, "package.json");
11881
+ PLUGIN_SETTINGS_DIR_4 = join11(SESSION_DIR_4, ".claude");
11882
+ PLUGIN_SETTINGS_PATH_4 = join11(PLUGIN_SETTINGS_DIR_4, "settings.json");
11883
+ PROJECT_MCP_PATH_4 = join11(SESSION_DIR_4, ".mcp.json");
11884
+ RUN_SCRIPT_PATH_4 = join11(SESSION_DIR_4, "run-claude.sh");
11213
11885
  TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
11214
11886
  CHANNEL_4_PORT = 8952;
11215
11887
  RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
@@ -11484,9 +12156,9 @@ __export(disconnect_exports, {
11484
12156
  disconnectCommand: () => disconnectCommand
11485
12157
  });
11486
12158
  import { existsSync as existsSync13, rmSync, readdirSync as readdirSync4 } from "fs";
11487
- import { homedir as homedir11 } from "os";
11488
- import { join as join11 } from "path";
11489
- import { spawnSync as spawnSync5 } from "child_process";
12159
+ import { homedir as homedir12 } from "os";
12160
+ import { join as join12 } from "path";
12161
+ import { spawnSync as spawnSync6 } from "child_process";
11490
12162
  import { createInterface as createInterface4 } from "readline";
11491
12163
  async function tearDownLocalCC() {
11492
12164
  const docker = dockerStatus();
@@ -11500,7 +12172,7 @@ async function tearDownLocalCC() {
11500
12172
  console.log("\u2713 removed synkro-server container");
11501
12173
  try {
11502
12174
  const image = imageTag();
11503
- const r = spawnSync5("docker", ["rmi", "-f", image], { encoding: "utf-8", timeout: 3e4 });
12175
+ const r = spawnSync6("docker", ["rmi", "-f", image], { encoding: "utf-8", timeout: 3e4 });
11504
12176
  console.log(r.status === 0 ? `\u2713 removed Docker image ${image}` : "\xB7 no Docker image to remove");
11505
12177
  } catch {
11506
12178
  }
@@ -11559,15 +12231,19 @@ async function disconnectCommand(args2 = []) {
11559
12231
  const cursorMcpRemoved = uninstallCursorMcpConfig();
11560
12232
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
11561
12233
  }
11562
- if (existsSync13(SYNKRO_DIR5)) {
12234
+ {
12235
+ const desktopMcpRemoved = uninstallClaudeDesktopMcpConfig();
12236
+ console.log(`${desktopMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Claude Desktop): ${desktopMcpRemoved ? "removed from claude_desktop_config.json" : "no entry found"}`);
12237
+ }
12238
+ if (existsSync13(SYNKRO_DIR6)) {
11563
12239
  if (purge) {
11564
- rmSync(SYNKRO_DIR5, { recursive: true, force: true });
11565
- console.log(`\u2713 wiped ${SYNKRO_DIR5} entirely \u2014 including all scan data and backups`);
12240
+ rmSync(SYNKRO_DIR6, { recursive: true, force: true });
12241
+ console.log(`\u2713 wiped ${SYNKRO_DIR6} entirely \u2014 including all scan data and backups`);
11566
12242
  } else {
11567
- const keep = /* @__PURE__ */ new Set([join11(SYNKRO_DIR5, "pgdata"), join11(SYNKRO_DIR5, "pgdata-backups")]);
12243
+ const keep = /* @__PURE__ */ new Set([join12(SYNKRO_DIR6, "pgdata"), join12(SYNKRO_DIR6, "pgdata-backups")]);
11568
12244
  const preserved = [];
11569
- for (const entry of readdirSync4(SYNKRO_DIR5)) {
11570
- const full = join11(SYNKRO_DIR5, entry);
12245
+ for (const entry of readdirSync4(SYNKRO_DIR6)) {
12246
+ const full = join12(SYNKRO_DIR6, entry);
11571
12247
  if (keep.has(full)) {
11572
12248
  preserved.push(entry);
11573
12249
  continue;
@@ -11575,18 +12251,18 @@ async function disconnectCommand(args2 = []) {
11575
12251
  rmSync(full, { recursive: true, force: true });
11576
12252
  }
11577
12253
  if (preserved.length > 0) {
11578
- console.log(`\u2713 removed Synkro config from ${SYNKRO_DIR5} (kept your scan data: ${preserved.join(", ")})`);
12254
+ console.log(`\u2713 removed Synkro config from ${SYNKRO_DIR6} (kept your scan data: ${preserved.join(", ")})`);
11579
12255
  console.log(" run `synkro uninstall --purge` to delete that too");
11580
12256
  } else {
11581
- console.log(`\u2713 removed ${SYNKRO_DIR5}`);
12257
+ console.log(`\u2713 removed ${SYNKRO_DIR6}`);
11582
12258
  }
11583
12259
  }
11584
12260
  } else {
11585
- console.log(`\xB7 ${SYNKRO_DIR5} already gone`);
12261
+ console.log(`\xB7 ${SYNKRO_DIR6} already gone`);
11586
12262
  }
11587
12263
  console.log(purge ? "\nSynkro fully removed \u2014 this machine is clean, as if it was never installed." : "\nSynkro uninstalled. Your scan data is preserved.");
11588
12264
  }
11589
- var SYNKRO_DIR5;
12265
+ var SYNKRO_DIR6;
11590
12266
  var init_disconnect = __esm({
11591
12267
  "cli/commands/disconnect.ts"() {
11592
12268
  "use strict";
@@ -11597,14 +12273,14 @@ var init_disconnect = __esm({
11597
12273
  init_install2();
11598
12274
  init_dockerInstall();
11599
12275
  init_macKeychain();
11600
- SYNKRO_DIR5 = join11(homedir11(), ".synkro");
12276
+ SYNKRO_DIR6 = join12(homedir12(), ".synkro");
11601
12277
  }
11602
12278
  });
11603
12279
 
11604
12280
  // cli/local-cc/turnLog.ts
11605
- import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync11, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
11606
- import { dirname as dirname6, join as join12 } from "path";
11607
- import { homedir as homedir12 } from "os";
12281
+ import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync11, openSync as openSync2, readFileSync as readFileSync12, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
12282
+ import { dirname as dirname6, join as join13 } from "path";
12283
+ import { homedir as homedir13 } from "os";
11608
12284
  function truncate(s, max = PREVIEW_MAX) {
11609
12285
  if (s.length <= max) return s;
11610
12286
  return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
@@ -11642,9 +12318,9 @@ function appendTurn(args2) {
11642
12318
  function readRecentTurns(n = 20) {
11643
12319
  if (!existsSync14(TURN_LOG_PATH)) return [];
11644
12320
  try {
11645
- const size = statSync3(TURN_LOG_PATH).size;
12321
+ const size = statSync2(TURN_LOG_PATH).size;
11646
12322
  if (size === 0) return [];
11647
- const text = readFileSync11(TURN_LOG_PATH, "utf-8");
12323
+ const text = readFileSync12(TURN_LOG_PATH, "utf-8");
11648
12324
  const lines = text.split("\n").filter(Boolean);
11649
12325
  const lastN = lines.slice(-n).reverse();
11650
12326
  return lastN.map((line) => {
@@ -11668,7 +12344,7 @@ function followTurns(onEntry) {
11668
12344
  }
11669
12345
  let lastSize = (() => {
11670
12346
  try {
11671
- return statSync3(TURN_LOG_PATH).size;
12347
+ return statSync2(TURN_LOG_PATH).size;
11672
12348
  } catch {
11673
12349
  return 0;
11674
12350
  }
@@ -11723,7 +12399,7 @@ var TURN_LOG_PATH, PREVIEW_MAX;
11723
12399
  var init_turnLog = __esm({
11724
12400
  "cli/local-cc/turnLog.ts"() {
11725
12401
  "use strict";
11726
- TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
12402
+ TURN_LOG_PATH = join13(homedir13(), ".synkro", "cc_sessions", "turns.log");
11727
12403
  PREVIEW_MAX = 400;
11728
12404
  }
11729
12405
  });
@@ -11872,19 +12548,19 @@ var init_grade = __esm({
11872
12548
  });
11873
12549
 
11874
12550
  // cli/local-cc/pueue.ts
11875
- import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn as spawn2 } from "child_process";
11876
- import { homedir as homedir13 } from "os";
11877
- import { join as join13 } from "path";
12551
+ import { execFileSync as execFileSync3, spawnSync as spawnSync7, spawn as spawn2 } from "child_process";
12552
+ import { homedir as homedir14 } from "os";
12553
+ import { join as join14 } from "path";
11878
12554
  import { connect as connect2 } from "net";
11879
12555
  function pueueAvailable() {
11880
- const r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
12556
+ const r = spawnSync7("pueue", ["--version"], { encoding: "utf-8" });
11881
12557
  if (r.status !== 0) {
11882
12558
  throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
11883
12559
  }
11884
12560
  }
11885
12561
  function statusJson() {
11886
12562
  pueueAvailable();
11887
- const r = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8" });
12563
+ const r = spawnSync7("pueue", ["status", "--json"], { encoding: "utf-8" });
11888
12564
  if (r.status !== 0) {
11889
12565
  throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
11890
12566
  }
@@ -11929,18 +12605,18 @@ function startTask(opts = {}) {
11929
12605
  let existing = findTask(ch);
11930
12606
  while (existing) {
11931
12607
  if (existing.status === "Running" || existing.status === "Queued") {
11932
- spawnSync6("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
11933
- spawnSync6("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
12608
+ spawnSync7("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
12609
+ spawnSync7("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
11934
12610
  for (let i = 0; i < 10; i++) {
11935
12611
  const check = findTask(ch);
11936
12612
  if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
11937
- spawnSync6("sleep", ["0.5"], { encoding: "utf-8" });
12613
+ spawnSync7("sleep", ["0.5"], { encoding: "utf-8" });
11938
12614
  }
11939
12615
  }
11940
- spawnSync6("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
12616
+ spawnSync7("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
11941
12617
  existing = findTask(ch);
11942
12618
  }
11943
- const runScript = join13(cwd, "run-claude.sh");
12619
+ const runScript = join14(cwd, "run-claude.sh");
11944
12620
  const args2 = [
11945
12621
  "add",
11946
12622
  "--label",
@@ -11951,7 +12627,7 @@ function startTask(opts = {}) {
11951
12627
  "bash",
11952
12628
  runScript
11953
12629
  ];
11954
- const r = spawnSync6("pueue", args2, { encoding: "utf-8" });
12630
+ const r = spawnSync7("pueue", args2, { encoding: "utf-8" });
11955
12631
  if (r.status !== 0) {
11956
12632
  throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
11957
12633
  }
@@ -11962,25 +12638,25 @@ function startTask(opts = {}) {
11962
12638
  return created;
11963
12639
  }
11964
12640
  function stopTask(channel = CHANNEL_PRIMARY) {
11965
- spawnSync6("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
12641
+ spawnSync7("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
11966
12642
  let t = findTask(channel);
11967
12643
  while (t) {
11968
12644
  if (t.status === "Running" || t.status === "Queued") {
11969
- spawnSync6("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
12645
+ spawnSync7("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
11970
12646
  for (let i = 0; i < 10; i++) {
11971
12647
  const check = findTask(channel);
11972
12648
  if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
11973
- spawnSync6("sleep", ["0.5"], { encoding: "utf-8" });
12649
+ spawnSync7("sleep", ["0.5"], { encoding: "utf-8" });
11974
12650
  }
11975
12651
  }
11976
- spawnSync6("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
12652
+ spawnSync7("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
11977
12653
  t = findTask(channel);
11978
12654
  }
11979
12655
  }
11980
12656
  function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
11981
12657
  const t = findTask(channel);
11982
12658
  if (!t) return `(no ${channel.taskLabel} task)`;
11983
- const r = spawnSync6("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
12659
+ const r = spawnSync7("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
11984
12660
  return r.stdout || r.stderr || "(no output)";
11985
12661
  }
11986
12662
  function ensureRunning(opts = {}) {
@@ -12005,8 +12681,8 @@ function probePort(host, port, timeoutMs = 500) {
12005
12681
  });
12006
12682
  }
12007
12683
  function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
12008
- spawnSync6("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
12009
- spawnSync6("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
12684
+ spawnSync7("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
12685
+ spawnSync7("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
12010
12686
  }
12011
12687
  async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
12012
12688
  const deadline = Date.now() + timeoutMs;
@@ -12018,46 +12694,46 @@ async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tm
12018
12694
  return probePort(host, port);
12019
12695
  }
12020
12696
  function brewInstall(pkg) {
12021
- const brew = spawnSync6("brew", ["--version"], { encoding: "utf-8" });
12697
+ const brew = spawnSync7("brew", ["--version"], { encoding: "utf-8" });
12022
12698
  if (brew.status !== 0) return false;
12023
12699
  console.log(` Installing ${pkg} via brew...`);
12024
- const r = spawnSync6("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
12700
+ const r = spawnSync7("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
12025
12701
  return r.status === 0;
12026
12702
  }
12027
12703
  function assertPueueInstalled() {
12028
- let r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
12704
+ let r = spawnSync7("pueue", ["--version"], { encoding: "utf-8" });
12029
12705
  if (r.status !== 0) {
12030
12706
  if (process.platform === "darwin" && brewInstall("pueue")) {
12031
- r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
12707
+ r = spawnSync7("pueue", ["--version"], { encoding: "utf-8" });
12032
12708
  if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
12033
12709
  } else {
12034
12710
  throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
12035
12711
  }
12036
12712
  }
12037
- const status = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
12713
+ const status = spawnSync7("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
12038
12714
  if (status.status !== 0) {
12039
12715
  console.log(" Starting pueued daemon...");
12040
12716
  const child = spawn2("pueued", ["-d"], { stdio: "ignore", detached: true });
12041
12717
  child.unref();
12042
- spawnSync6("sleep", ["1"]);
12043
- const retry = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
12718
+ spawnSync7("sleep", ["1"]);
12719
+ const retry = spawnSync7("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
12044
12720
  if (retry.status !== 0) {
12045
12721
  throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
12046
12722
  }
12047
12723
  }
12048
- spawnSync6("pueue", ["parallel", "2"], { encoding: "utf-8" });
12724
+ spawnSync7("pueue", ["parallel", "2"], { encoding: "utf-8" });
12049
12725
  }
12050
12726
  function assertClaudeInstalled() {
12051
- const r = spawnSync6("claude", ["--version"], { encoding: "utf-8" });
12727
+ const r = spawnSync7("claude", ["--version"], { encoding: "utf-8" });
12052
12728
  if (r.status !== 0) {
12053
12729
  throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
12054
12730
  }
12055
12731
  }
12056
12732
  function assertTmuxInstalled() {
12057
- let r = spawnSync6("tmux", ["-V"], { encoding: "utf-8" });
12733
+ let r = spawnSync7("tmux", ["-V"], { encoding: "utf-8" });
12058
12734
  if (r.status !== 0) {
12059
12735
  if (process.platform === "darwin" && brewInstall("tmux")) {
12060
- r = spawnSync6("tmux", ["-V"], { encoding: "utf-8" });
12736
+ r = spawnSync7("tmux", ["-V"], { encoding: "utf-8" });
12061
12737
  if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
12062
12738
  } else {
12063
12739
  throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
@@ -12070,12 +12746,12 @@ var init_pueue = __esm({
12070
12746
  "use strict";
12071
12747
  TASK_LABEL = "synkro-local-cc";
12072
12748
  TMUX_SESSION = "synkro-local-cc";
12073
- SESSION_DIR2 = join13(homedir13(), ".synkro", "cc_sessions");
12749
+ SESSION_DIR2 = join14(homedir14(), ".synkro", "cc_sessions");
12074
12750
  TASK_LABEL_2 = "synkro-local-cc-2";
12075
12751
  TMUX_SESSION_2 = "synkro-local-cc-2";
12076
- SESSION_DIR_22 = join13(homedir13(), ".synkro", "cc_sessions_2");
12077
- SESSION_DIR_32 = join13(homedir13(), ".synkro", "cc_sessions_3");
12078
- SESSION_DIR_42 = join13(homedir13(), ".synkro", "cc_sessions_4");
12752
+ SESSION_DIR_22 = join14(homedir14(), ".synkro", "cc_sessions_2");
12753
+ SESSION_DIR_32 = join14(homedir14(), ".synkro", "cc_sessions_3");
12754
+ SESSION_DIR_42 = join14(homedir14(), ".synkro", "cc_sessions_4");
12079
12755
  PueueError = class extends Error {
12080
12756
  constructor(message, cause) {
12081
12757
  super(message);
@@ -12090,13 +12766,13 @@ var init_pueue = __esm({
12090
12766
  });
12091
12767
 
12092
12768
  // cli/local-cc/settings.ts
12093
- import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
12094
- import { homedir as homedir14 } from "os";
12095
- import { join as join14 } from "path";
12769
+ import { existsSync as existsSync15, readFileSync as readFileSync13 } from "fs";
12770
+ import { homedir as homedir15 } from "os";
12771
+ import { join as join15 } from "path";
12096
12772
  function isLocalCCEnabled() {
12097
12773
  if (!existsSync15(CONFIG_PATH3)) return false;
12098
12774
  try {
12099
- const content = readFileSync12(CONFIG_PATH3, "utf-8");
12775
+ const content = readFileSync13(CONFIG_PATH3, "utf-8");
12100
12776
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
12101
12777
  return match?.[1] === "yes";
12102
12778
  } catch {
@@ -12107,7 +12783,7 @@ var CONFIG_PATH3;
12107
12783
  var init_settings = __esm({
12108
12784
  "cli/local-cc/settings.ts"() {
12109
12785
  "use strict";
12110
- CONFIG_PATH3 = join14(homedir14(), ".synkro", "config.env");
12786
+ CONFIG_PATH3 = join15(homedir15(), ".synkro", "config.env");
12111
12787
  }
12112
12788
  });
12113
12789
 
@@ -12116,11 +12792,11 @@ var localCc_exports = {};
12116
12792
  __export(localCc_exports, {
12117
12793
  localCcCommand: () => localCcCommand
12118
12794
  });
12119
- import { spawnSync as spawnSync7 } from "child_process";
12120
- import { homedir as homedir15 } from "os";
12121
- import { join as join15 } from "path";
12795
+ import { spawnSync as spawnSync8 } from "child_process";
12796
+ import { homedir as homedir16 } from "os";
12797
+ import { join as join16 } from "path";
12122
12798
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
12123
- import { existsSync as existsSync16, readFileSync as readFileSync13, writeFileSync as writeFileSync10 } from "fs";
12799
+ import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
12124
12800
  function deploymentMode() {
12125
12801
  const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
12126
12802
  if (env === "docker") return "docker";
@@ -12227,14 +12903,14 @@ TROUBLESHOOTING
12227
12903
  }
12228
12904
  function readGatewayUrl() {
12229
12905
  if (existsSync16(CONFIG_PATH4)) {
12230
- const m = readFileSync13(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
12906
+ const m = readFileSync14(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
12231
12907
  if (m) return m[1];
12232
12908
  }
12233
12909
  return "https://api.synkro.sh";
12234
12910
  }
12235
12911
  function updateLocalInferenceFlag(enabled) {
12236
12912
  if (!existsSync16(CONFIG_PATH4)) return;
12237
- let content = readFileSync13(CONFIG_PATH4, "utf-8");
12913
+ let content = readFileSync14(CONFIG_PATH4, "utf-8");
12238
12914
  const flag = enabled ? "yes" : "no";
12239
12915
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
12240
12916
  content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
@@ -12297,7 +12973,7 @@ async function cmdStatus() {
12297
12973
  }
12298
12974
  const ch1Up = await isChannelAvailable();
12299
12975
  console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
12300
- const tmux1 = spawnSync7("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
12976
+ const tmux1 = spawnSync8("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
12301
12977
  console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
12302
12978
  const t2 = findTask(CHANNEL_SECONDARY);
12303
12979
  if (!t2) {
@@ -12307,7 +12983,7 @@ async function cmdStatus() {
12307
12983
  }
12308
12984
  const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
12309
12985
  console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
12310
- const tmux2 = spawnSync7("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME_2}`], { encoding: "utf-8" });
12986
+ const tmux2 = spawnSync8("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME_2}`], { encoding: "utf-8" });
12311
12987
  console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
12312
12988
  }
12313
12989
  async function cmdEnable() {
@@ -12513,7 +13189,7 @@ function cmdLogs(rest) {
12513
13189
  }
12514
13190
  return "200";
12515
13191
  })();
12516
- spawnSync7("docker", ["logs", "--tail", tailArg, ...followFlag, "synkro-server"], { stdio: "inherit" });
13192
+ spawnSync8("docker", ["logs", "--tail", tailArg, ...followFlag, "synkro-server"], { stdio: "inherit" });
12517
13193
  return;
12518
13194
  }
12519
13195
  for (const arg of rest) {
@@ -12561,7 +13237,7 @@ function cmdLogs(rest) {
12561
13237
  function cmdAttach(rest) {
12562
13238
  assertTmuxInstalled();
12563
13239
  const readonly = rest.some((a) => a === "--readonly" || a === "-r");
12564
- const has = spawnSync7("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
13240
+ const has = spawnSync8("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
12565
13241
  if (has.status !== 0) {
12566
13242
  console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
12567
13243
  process.exit(1);
@@ -12574,7 +13250,7 @@ function cmdAttach(rest) {
12574
13250
  console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
12575
13251
  console.log();
12576
13252
  const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
12577
- const r = spawnSync7("tmux", args2, { stdio: "inherit" });
13253
+ const r = spawnSync8("tmux", args2, { stdio: "inherit" });
12578
13254
  process.exit(r.status ?? 0);
12579
13255
  }
12580
13256
  async function cmdTest() {
@@ -12673,8 +13349,8 @@ var init_localCc = __esm({
12673
13349
  init_install();
12674
13350
  init_client();
12675
13351
  init_stub();
12676
- SYNKRO_CONFIG_PATH = join15(homedir15(), ".synkro", "config.env");
12677
- CONFIG_PATH4 = join15(homedir15(), ".synkro", "config.env");
13352
+ SYNKRO_CONFIG_PATH = join16(homedir16(), ".synkro", "config.env");
13353
+ CONFIG_PATH4 = join16(homedir16(), ".synkro", "config.env");
12678
13354
  }
12679
13355
  });
12680
13356
 
@@ -12785,13 +13461,13 @@ var config_exports = {};
12785
13461
  __export(config_exports, {
12786
13462
  configCommand: () => configCommand
12787
13463
  });
12788
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
12789
- import { join as join16 } from "path";
12790
- import { homedir as homedir16 } from "os";
13464
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
13465
+ import { join as join17 } from "path";
13466
+ import { homedir as homedir17 } from "os";
12791
13467
  function readConfigEnv() {
12792
13468
  if (!existsSync17(CONFIG_PATH5)) return {};
12793
13469
  const out = {};
12794
- for (const line of readFileSync14(CONFIG_PATH5, "utf-8").split("\n")) {
13470
+ for (const line of readFileSync15(CONFIG_PATH5, "utf-8").split("\n")) {
12795
13471
  const t = line.trim();
12796
13472
  if (!t || t.startsWith("#")) continue;
12797
13473
  const eq = t.indexOf("=");
@@ -12804,7 +13480,7 @@ function updateConfigValue(key, value) {
12804
13480
  console.error("No config found. Run `synkro install` first.");
12805
13481
  process.exit(1);
12806
13482
  }
12807
- const lines = readFileSync14(CONFIG_PATH5, "utf-8").split("\n");
13483
+ const lines = readFileSync15(CONFIG_PATH5, "utf-8").split("\n");
12808
13484
  const pattern = new RegExp(`^${key}=`);
12809
13485
  let found = false;
12810
13486
  const updated = lines.map((line) => {
@@ -12920,25 +13596,25 @@ To change:`);
12920
13596
  updateConfigValue("SYNKRO_INFERENCE", inferenceValue);
12921
13597
  console.log(`\u2713 Inference set to '${inferenceValue}'.`);
12922
13598
  }
12923
- var SYNKRO_DIR6, CONFIG_PATH5;
13599
+ var SYNKRO_DIR7, CONFIG_PATH5;
12924
13600
  var init_config = __esm({
12925
13601
  "cli/commands/config.ts"() {
12926
13602
  "use strict";
12927
13603
  init_stub();
12928
- SYNKRO_DIR6 = join16(homedir16(), ".synkro");
12929
- CONFIG_PATH5 = join16(SYNKRO_DIR6, "config.env");
13604
+ SYNKRO_DIR7 = join17(homedir17(), ".synkro");
13605
+ CONFIG_PATH5 = join17(SYNKRO_DIR7, "config.env");
12930
13606
  }
12931
13607
  });
12932
13608
 
12933
13609
  // cli/bootstrap.js
12934
- import { readFileSync as readFileSync15, existsSync as existsSync18 } from "fs";
13610
+ import { readFileSync as readFileSync16, existsSync as existsSync18 } from "fs";
12935
13611
  import { resolve as resolve3 } from "path";
12936
13612
  var envCandidates = [
12937
13613
  resolve3(process.env.HOME ?? "", ".synkro", "config.env")
12938
13614
  ];
12939
13615
  for (const envPath of envCandidates) {
12940
13616
  if (!existsSync18(envPath)) continue;
12941
- const envContent = readFileSync15(envPath, "utf-8");
13617
+ const envContent = readFileSync16(envPath, "utf-8");
12942
13618
  for (const line of envContent.split("\n")) {
12943
13619
  const trimmed = line.trim();
12944
13620
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -12953,7 +13629,7 @@ var args = process.argv.slice(2);
12953
13629
  var cmd = args[0] || "";
12954
13630
  var subArgs = args.slice(1);
12955
13631
  function printVersion() {
12956
- console.log("1.6.57");
13632
+ console.log("1.6.59");
12957
13633
  }
12958
13634
  function printHelp2() {
12959
13635
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -13067,7 +13743,7 @@ async function main() {
13067
13743
  }
13068
13744
  case "claude-desktop": {
13069
13745
  const { runClaudeDesktopTap: runClaudeDesktopTap2 } = await Promise.resolve().then(() => (init_claudeDesktopTap(), claudeDesktopTap_exports));
13070
- await runClaudeDesktopTap2();
13746
+ await runClaudeDesktopTap2({ backfill: subArgs.includes("--backfill") });
13071
13747
  break;
13072
13748
  }
13073
13749
  default: {