@synkro-sh/cli 1.6.52 → 1.6.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -193,6 +193,19 @@ function installCCHooks(settingsPath, config) {
193
193
  ],
194
194
  [SYNKRO_MARKER]: true
195
195
  });
196
+ if (config.taskActivateIntentScriptPath) {
197
+ settings.hooks.PreToolUse.push({
198
+ matcher: "mcp__synkro-guardrails__activate_standard",
199
+ hooks: [
200
+ {
201
+ type: "command",
202
+ command: config.taskActivateIntentScriptPath,
203
+ timeout: 5
204
+ }
205
+ ],
206
+ [SYNKRO_MARKER]: true
207
+ });
208
+ }
196
209
  settings.hooks.PostToolUse.push({
197
210
  matcher: "Bash",
198
211
  hooks: [
@@ -807,7 +820,7 @@ synkro_post_with_retry() {
807
820
  function stubHook(surface, optsLiteral) {
808
821
  return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
809
822
  }
810
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
823
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
811
824
  var init_hookScriptsTs = __esm({
812
825
  "cli/installer/hookScriptsTs.ts"() {
813
826
  "use strict";
@@ -1167,7 +1180,7 @@ export function findGitRoot(cwd: string): string {
1167
1180
  } catch { return ''; }
1168
1181
  }
1169
1182
 
1170
- // \u2500\u2500\u2500 .synkro file \u2500\u2500\u2500
1183
+ // \u2500\u2500\u2500 synkro.toml file \u2500\u2500\u2500
1171
1184
 
1172
1185
  export interface SynkroFileConfig {
1173
1186
  version: number;
@@ -1189,51 +1202,49 @@ const SYNKRO_FILE_DEFAULTS: SynkroFileConfig = {
1189
1202
  scanning: { cwe: true, cve: true },
1190
1203
  };
1191
1204
 
1192
- function parseSynkroYaml(raw: string): Record<string, any> {
1205
+ // Coerce a single TOML value token to JS.
1206
+ function parseTomlValue(raw: string): any {
1207
+ const v = raw.trim();
1208
+ if (v.startsWith('[') && v.endsWith(']')) {
1209
+ const inner = v.slice(1, -1).trim();
1210
+ if (!inner) return [];
1211
+ return inner.split(',').map((s: string) => parseTomlValue(s)).filter((s: any) => s !== '');
1212
+ }
1213
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
1214
+ if (v === 'true') return true;
1215
+ if (v === 'false') return false;
1216
+ if (/^-?\\d+$/.test(v)) return parseInt(v, 10);
1217
+ return v;
1218
+ }
1219
+
1220
+ // Minimal TOML reader for synkro.toml's fixed schema (top-level scalars/arrays
1221
+ // + [grader]/[scanning] tables). Dependency-free \u2014 this runs inside the
1222
+ // materialized _synkro-common.ts which has no node_modules. Returns the same
1223
+ // nested-object shape the old YAML parser did.
1224
+ function parseSynkroToml(raw: string): Record<string, any> {
1193
1225
  const result: Record<string, any> = {};
1194
- const lines = raw.split('\\n');
1195
- let currentKey = '';
1196
- let currentObj: Record<string, any> | null = null;
1197
- let currentArr: string[] | null = null;
1198
-
1199
- for (const line of lines) {
1200
- if (!line.trim() || line.trim().startsWith('#')) continue;
1201
-
1202
- if (line.match(/^\\S/) && line.includes(':')) {
1203
- if (currentObj && currentKey) result[currentKey] = currentObj;
1204
- if (currentArr && currentKey) result[currentKey] = currentArr;
1205
- currentObj = null;
1206
- currentArr = null;
1207
-
1208
- const colonIdx = line.indexOf(':');
1209
- const key = line.slice(0, colonIdx).trim();
1210
- const val = line.slice(colonIdx + 1).trim();
1211
- currentKey = key;
1212
-
1213
- if (val) {
1214
- if (val === '[]') result[key] = [];
1215
- else if (val === 'true') result[key] = true;
1216
- else if (val === 'false') result[key] = false;
1217
- else if (/^\\d+$/.test(val)) result[key] = parseInt(val, 10);
1218
- else result[key] = val;
1219
- currentKey = '';
1220
- }
1221
- } else if (line.match(/^ - /)) {
1222
- if (!currentArr) currentArr = [];
1223
- currentArr.push(line.replace(/^ - /, '').trim());
1224
- } else if (line.match(/^ \\S/) && line.includes(':')) {
1225
- if (!currentObj) currentObj = {};
1226
- const colonIdx = line.indexOf(':');
1227
- const k = line.slice(0, colonIdx).trim();
1228
- const v = line.slice(colonIdx + 1).trim();
1229
- if (v === 'true') currentObj[k] = true;
1230
- else if (v === 'false') currentObj[k] = false;
1231
- else if (/^\\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
1232
- else currentObj[k] = v;
1233
- }
1234
- }
1235
- if (currentObj && currentKey) result[currentKey] = currentObj;
1236
- if (currentArr && currentKey) result[currentKey] = currentArr;
1226
+ let section: string | null = null;
1227
+ for (const rawLine of raw.split('\\n')) {
1228
+ const line = rawLine.trim();
1229
+ if (!line || line.startsWith('#')) continue;
1230
+ const sec = line.match(/^\\[([^\\]]+)\\]$/);
1231
+ if (sec) {
1232
+ section = sec[1].trim();
1233
+ if (!result[section]) result[section] = {};
1234
+ continue;
1235
+ }
1236
+ const eq = line.indexOf('=');
1237
+ if (eq === -1) continue;
1238
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, '');
1239
+ let valRaw = line.slice(eq + 1).trim();
1240
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith('[')) {
1241
+ const h = valRaw.indexOf('#');
1242
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
1243
+ }
1244
+ const value = parseTomlValue(valRaw);
1245
+ if (section) (result[section] as Record<string, any>)[key] = value;
1246
+ else result[key] = value;
1247
+ }
1237
1248
  return result;
1238
1249
  }
1239
1250
 
@@ -1266,11 +1277,11 @@ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
1266
1277
  if (_synkroFileCache) return _synkroFileCache;
1267
1278
  const root = cwd ? findGitRoot(cwd) : '';
1268
1279
  if (!root) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1269
- const fp = root + '/.synkro';
1280
+ const fp = root + '/synkro.toml';
1270
1281
  try {
1271
1282
  if (!existsSync(fp)) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1272
1283
  const raw = readFileSync(fp, 'utf-8');
1273
- const parsed = raw.trimStart().startsWith('{') ? JSON.parse(raw) : parseSynkroYaml(raw);
1284
+ const parsed = parseSynkroToml(raw);
1274
1285
  const validHarness = ['claude-code', 'cursor'] as const;
1275
1286
  const harness = Array.isArray(parsed.harness)
1276
1287
  ? parsed.harness.filter((h: string) => validHarness.includes(h as any))
@@ -1309,13 +1320,13 @@ export function effectiveGraderPool(synkroFile: SynkroFileConfig, hookAgentKind:
1309
1320
  return 'cursor';
1310
1321
  }
1311
1322
 
1312
- // \u2500\u2500\u2500 .synkro presence (per-repo onboarding) \u2500\u2500\u2500
1313
- // A repo is "onboarded" to Synkro when it has a .synkro file at its git root.
1314
- // The hooks are installed globally, so they fire in every repo \u2014 including ones
1315
- // the user never set up. Without a .synkro there, grading is SKIPPED gracefully
1316
- // rather than attempting a grade that surfaces a confusing "grader unavailable"
1317
- // error: the tool call passes through and we show a one-time hint pointing at
1318
- // "synkro install".
1323
+ // \u2500\u2500\u2500 synkro.toml presence (per-repo onboarding) \u2500\u2500\u2500
1324
+ // A repo is "onboarded" to Synkro when it has a synkro.toml file at its git
1325
+ // root. The hooks are installed globally, so they fire in every repo \u2014
1326
+ // including ones the user never set up. Without a synkro.toml there, grading is
1327
+ // SKIPPED gracefully rather than attempting a grade that surfaces a confusing
1328
+ // "grader unavailable" error: the tool call passes through and we show a
1329
+ // one-time hint pointing at "synkro install".
1319
1330
  export function synkroFilePresent(cwd?: string): boolean {
1320
1331
  try {
1321
1332
  const root = cwd ? findGitRoot(cwd) : '';
@@ -1325,18 +1336,17 @@ export function synkroFilePresent(cwd?: string): boolean {
1325
1336
  // under ~), never treat it as onboarded \u2014 otherwise grading fires in every
1326
1337
  // directory under home.
1327
1338
  if (root === HOME) return false;
1328
- // A repo is onboarded ONLY via a regular .synkro FILE at its root. statSync +
1329
- // isFile is required because existsSync also matches a DIRECTORY named
1330
- // .synkro (exactly what ~/.synkro is) \u2014 the root cause of grading running in
1331
- // repos that have no .synkro file.
1332
- return statSync(root + '/.synkro').isFile();
1339
+ // A repo is onboarded ONLY via a regular synkro.toml FILE at its root.
1340
+ // statSync + isFile guards against a directory of that name being mistaken
1341
+ // for config.
1342
+ return statSync(root + '/synkro.toml').isFile();
1333
1343
  } catch { return false; }
1334
1344
  }
1335
1345
 
1336
1346
  const NO_SYNKRO_HINT_DIR = join(HOME, '.synkro', '.no-synkro-hint');
1337
1347
 
1338
1348
  // Returns the onboarding hint the FIRST time a session touches a repo with no
1339
- // .synkro file, then null on subsequent calls so it doesn't repeat on every
1349
+ // synkro.toml file, then null on subsequent calls so it doesn't repeat on every
1340
1350
  // tool call. Caller passes null straight through to outputEmpty().
1341
1351
  export function noSynkroSkipMessage(sessionId: string): string | null {
1342
1352
  try {
@@ -1348,7 +1358,7 @@ export function noSynkroSkipMessage(sessionId: string): string | null {
1348
1358
  } catch {
1349
1359
  // best-effort dedup \u2014 if the marker can't be written, still show the hint
1350
1360
  }
1351
- return '[synkro] No .synkro config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1361
+ return '[synkro] No synkro.toml config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1352
1362
  }
1353
1363
 
1354
1364
  // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
@@ -1660,6 +1670,13 @@ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
1660
1670
  'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
1661
1671
  'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
1662
1672
  'git',
1673
+ // Read-only system / inspection utilities (no mutation, no exec). Deliberately
1674
+ // EXCLUDES curl/wget (exfil + -o write), sed/awk (w/e write+exec), env (command
1675
+ // runner), tree (-o write) \u2014 those stay graded. Keep in sync with scanning/safeRead.ts.
1676
+ 'lsof','ps','df','du','uname','hostname','uptime',
1677
+ 'basename','dirname','realpath','readlink',
1678
+ 'shasum','md5','md5sum','sha256sum','cksum',
1679
+ 'docker',
1663
1680
  ]);
1664
1681
  const tokens = seg.trim().split(' ').filter(t => t.length > 0);
1665
1682
  const verb = tokens[0] || '';
@@ -1689,6 +1706,15 @@ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
1689
1706
  } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
1690
1707
  const sub = tokens[1] || '';
1691
1708
  if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
1709
+ } else if (verb === 'docker') {
1710
+ // Read-only docker subcommands only \u2014 same gating pattern as git. Excludes
1711
+ // run/exec/rm/rmi/kill/stop/start/build/buildx/push/pull/cp/commit/save/load/
1712
+ // compose and every mutating verb; those fall through to the judge.
1713
+ const SAFE_DOCKER = new Set([
1714
+ 'ps','logs','images','inspect','version','info','top','port','history','diff',
1715
+ ]);
1716
+ const sub = tokens[1] || '';
1717
+ if (!SAFE_DOCKER.has(sub)) return false;
1692
1718
  }
1693
1719
  if (!repoRoot) return false;
1694
1720
  for (let i = 1; i < tokens.length; i++) {
@@ -2056,6 +2082,7 @@ export function dispatchCapture(
2056
2082
  ccModel?: string;
2057
2083
  linesAdded?: number;
2058
2084
  linesRemoved?: number;
2085
+ codeChange?: string;
2059
2086
  },
2060
2087
  ): void {
2061
2088
  // Fire-and-forget
@@ -2091,6 +2118,7 @@ export function dispatchCapture(
2091
2118
  if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
2092
2119
  if (opts.linesAdded != null) localBody.lines_added = opts.linesAdded;
2093
2120
  if (opts.linesRemoved != null) localBody.lines_removed = opts.linesRemoved;
2121
+ if (opts.codeChange) localBody.code_change = opts.codeChange;
2094
2122
  }
2095
2123
  appendLocalTelemetry(localBody);
2096
2124
 
@@ -2102,6 +2130,9 @@ export function dispatchCapture(
2102
2130
  if (opts.rulesChecked) body.rules_checked = opts.rulesChecked;
2103
2131
  if (opts.violatedRules) body.violated_rules = opts.violatedRules;
2104
2132
  if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
2133
+ if (opts.linesAdded != null) body.lines_added = opts.linesAdded;
2134
+ if (opts.linesRemoved != null) body.lines_removed = opts.linesRemoved;
2135
+ if (opts.codeChange) body.code_change = opts.codeChange;
2105
2136
  }
2106
2137
  shipCloud(jwt, '/api/v1/hook/capture', body);
2107
2138
  }
@@ -3142,6 +3173,8 @@ export type ScanFindingInput = {
3142
3173
  aliases?: string[];
3143
3174
  references?: Array<{ type: string; url: string }>;
3144
3175
  cwe_name?: string;
3176
+ code_snippet?: string;
3177
+ offending_lines?: string;
3145
3178
  };
3146
3179
 
3147
3180
  /** Persist open scan_findings rows for a block and flush the telemetry spool. */
@@ -3159,12 +3192,39 @@ export function emitBlockScanFindings(
3159
3192
  file_path: ctx.file_path,
3160
3193
  repo: ctx.repo,
3161
3194
  status: 'open',
3195
+ // These are BLOCK findings \u2014 Synkro denied the action, nothing landed.
3196
+ // prevented \u2192 recorded as auto-resolved on the server.
3197
+ prevented: true,
3162
3198
  ...f,
3163
3199
  }, captureDepth);
3164
3200
  }
3165
3201
  if (rows.length > 0) drainSpool().catch(() => {});
3166
3202
  }
3167
3203
 
3204
+ /**
3205
+ * Derive a 1-based file line range ("42" or "42-45") for a grader-reported
3206
+ * offending snippet by locating it verbatim in the reconstructed file. Mirrors
3207
+ * the container scanner's locateSnippetLines so cloud + local storage record
3208
+ * the same lines. Returns undefined when the snippet can't be located.
3209
+ */
3210
+ export function cweSnippetLines(haystack: string, snippet?: string): string | undefined {
3211
+ if (!haystack || !snippet) return undefined;
3212
+ const trimmed = snippet.replace(/^\\n+|\\n+$/g, '');
3213
+ if (!trimmed) return undefined;
3214
+ let idx = haystack.indexOf(trimmed);
3215
+ let spanLines = trimmed.split('\\n').length;
3216
+ if (idx < 0) {
3217
+ const firstLine = trimmed.split('\\n').map((l) => l.trim()).find(Boolean);
3218
+ if (!firstLine) return undefined;
3219
+ idx = haystack.indexOf(firstLine);
3220
+ if (idx < 0) return undefined;
3221
+ spanLines = 1;
3222
+ }
3223
+ const startLine = haystack.slice(0, idx).split('\\n').length;
3224
+ const endLine = startLine + spanLines - 1;
3225
+ return endLine > startLine ? startLine + '-' + endLine : String(startLine);
3226
+ }
3227
+
3168
3228
  export function dispatchFinding(
3169
3229
  jwt: string,
3170
3230
  finding: {
@@ -3183,6 +3243,9 @@ export function dispatchFinding(
3183
3243
  aliases?: string[];
3184
3244
  references?: Array<{ type: string; url: string }>;
3185
3245
  cwe_name?: string;
3246
+ code_snippet?: string;
3247
+ offending_lines?: string;
3248
+ prevented?: boolean;
3186
3249
  },
3187
3250
  captureDepth: string,
3188
3251
  ): void {
@@ -3209,6 +3272,9 @@ export function dispatchFinding(
3209
3272
  cwe_name: finding.cwe_name,
3210
3273
  detail: finding.detail,
3211
3274
  description: finding.description,
3275
+ code_snippet: finding.code_snippet,
3276
+ offending_lines: finding.offending_lines,
3277
+ prevented: finding.prevented,
3212
3278
  };
3213
3279
  shipCloud(jwt, '/api/v1/hook/finding', cloudBody);
3214
3280
  }
@@ -3339,7 +3405,7 @@ export function graderUnavailableMessage(hook: string, target: string, errorMess
3339
3405
  }
3340
3406
  if (isGraderNotConfigured(errorMessage)) {
3341
3407
  const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3342
- return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your .synkro file and run \`synkro install\`.';
3408
+ return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3343
3409
  }
3344
3410
  return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3345
3411
  }
@@ -3689,7 +3755,7 @@ import {
3689
3755
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3690
3756
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3691
3757
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3692
- extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3758
+ extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
3693
3759
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3694
3760
  loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3695
3761
  } from './_synkro-common.ts';
@@ -3725,6 +3791,8 @@ interface CweScanTarget {
3725
3791
  cweDiffSection: string;
3726
3792
  toolName: string;
3727
3793
  toolInput: any;
3794
+ /** Full reconstructed file (or written content) — used to locate snippet line ranges. */
3795
+ proposed: string;
3728
3796
  }
3729
3797
 
3730
3798
  const JS_DANGEROUS_MODULES = new Set([
@@ -3867,6 +3935,7 @@ async function main() {
3867
3935
  cweDiffSection: '',
3868
3936
  toolName: toolName || 'Shell',
3869
3937
  toolInput: {},
3938
+ proposed: w.content,
3870
3939
  });
3871
3940
  }
3872
3941
  if (targets.length === 0) { outputEmpty(); return; }
@@ -3901,7 +3970,7 @@ async function main() {
3901
3970
  cweContent = proposed.slice(0, 4000);
3902
3971
  }
3903
3972
 
3904
- targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput });
3973
+ targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput, proposed });
3905
3974
  } else {
3906
3975
  outputEmpty();
3907
3976
  return;
@@ -3927,6 +3996,7 @@ async function main() {
3927
3996
  const cweDiffSection = scan.cweDiffSection;
3928
3997
  const scanToolName = scan.toolName;
3929
3998
  const scanToolInput = scan.toolInput;
3999
+ const proposedFull = scan.proposed || scan.cweContent;
3930
4000
  const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3931
4001
  const fileShort = basename(filePath);
3932
4002
  const fileExt = extname(filePath).toLowerCase();
@@ -4012,12 +4082,15 @@ async function main() {
4012
4082
  { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
4013
4083
  activeCweIds.map((cweId) => {
4014
4084
  const f = findings.find((x: any) => x.cwe === cweId);
4085
+ const snippet = f?.code_snippet || undefined;
4015
4086
  return {
4016
4087
  finding_type: 'cwe' as const,
4017
4088
  finding_id: cweId,
4018
4089
  severity: f?.severity || 'high',
4019
4090
  detail: f?.reason || 'code weakness detected',
4020
4091
  cwe_name: f?.name || undefined,
4092
+ code_snippet: snippet,
4093
+ offending_lines: cweSnippetLines(proposedFull, snippet),
4021
4094
  };
4022
4095
  }),
4023
4096
  {
@@ -4147,6 +4220,7 @@ async function main() {
4147
4220
 
4148
4221
  const cweIds: string[] = [];
4149
4222
  const fixes: Record<string, string> = {};
4223
+ const snippets: Record<string, string> = {};
4150
4224
  let mergedReason = '';
4151
4225
  let mergedSeverity = '';
4152
4226
  let mergedCategory = '';
@@ -4169,6 +4243,19 @@ async function main() {
4169
4243
  for (let i = 0; i < Math.min(respIds.length, fMatches.length); i++) {
4170
4244
  if (!fixes[respIds[i]]) fixes[respIds[i]] = fMatches[i].replace(/<\/?suggested_fix>/g, '').trim();
4171
4245
  }
4246
+ // Per-<violation> pass for the verbatim snippet (code may contain '<'),
4247
+ // paired with its rule_id so each finding carries its own offending code.
4248
+ const vBlocks = gradeResp.match(/<violation>[\s\S]*?<\/violation>/g) || [];
4249
+ for (const block of vBlocks.slice(0, 8)) {
4250
+ const bId = block.match(/<rule_id>([^<]+)<\/rule_id>/)?.[1];
4251
+ if (!bId) continue;
4252
+ const id = bId.trim().replace(/^cwe-/i, 'CWE-');
4253
+ const snM = block.match(/<code_snippet>([\s\S]*?)<\/code_snippet>/);
4254
+ if (snM && !snippets[id]) {
4255
+ const snip = snM[1].replace(/^\n+|\n+$/g, '');
4256
+ if (snip.trim()) snippets[id] = snip;
4257
+ }
4258
+ }
4172
4259
  }
4173
4260
  }
4174
4261
 
@@ -4203,13 +4290,18 @@ async function main() {
4203
4290
  jwt,
4204
4291
  config.captureDepth,
4205
4292
  { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
4206
- activeCweIds.map((cweId) => ({
4207
- finding_type: 'cwe' as const,
4208
- finding_id: cweId,
4209
- severity: verdict.severity || 'high',
4210
- detail: verdict.reason || 'code weakness detected',
4211
- cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
4212
- })),
4293
+ activeCweIds.map((cweId) => {
4294
+ const snippet = snippets[cweId] || undefined;
4295
+ return {
4296
+ finding_type: 'cwe' as const,
4297
+ finding_id: cweId,
4298
+ severity: verdict.severity || 'high',
4299
+ detail: verdict.reason || 'code weakness detected',
4300
+ cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
4301
+ code_snippet: snippet,
4302
+ offending_lines: cweSnippetLines(proposedFull, snippet),
4303
+ };
4304
+ }),
4213
4305
  {
4214
4306
  finding_type: 'cwe',
4215
4307
  finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
@@ -5830,7 +5922,7 @@ async function main() {
5830
5922
  const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5831
5923
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
5832
5924
 
5833
- // No .synkro at the resolved repo root \u2192 Synkro is dormant here; allow
5925
+ // No synkro.toml at the resolved repo root \u2192 Synkro is dormant here; allow
5834
5926
  // without grading. Keyed off the validated detectRepo() root, not raw cwd.
5835
5927
  if (!repo || !synkroFilePresent(repo)) finishAllow();
5836
5928
 
@@ -6043,7 +6135,7 @@ async function main() {
6043
6135
  const filePath = String(payload.file_path || payload.path || payload.target_file || '');
6044
6136
  if (!filePath) finish();
6045
6137
 
6046
- // No .synkro at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6138
+ // No synkro.toml at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6047
6139
  const _root = detectRepo((typeof payload.cwd === 'string' && payload.cwd) || dirname(filePath), '', filePath, []);
6048
6140
  if (!_root || !isPathUnder(filePath, _root) || !synkroFilePresent(_root)) finish();
6049
6141
 
@@ -6222,7 +6314,7 @@ function gitRoot(cwd: string): string {
6222
6314
  } catch { return ''; }
6223
6315
  }
6224
6316
 
6225
- // Once-per-session onboarding hint for repos with no .synkro file.
6317
+ // Once-per-session onboarding hint for repos with no synkro.toml file.
6226
6318
  function noSynkroHint(sessionId: string): string | null {
6227
6319
  const dir = join(HOME, '.synkro', '.no-synkro-hint');
6228
6320
  try {
@@ -6232,7 +6324,7 @@ function noSynkroHint(sessionId: string): string | null {
6232
6324
  if (existsSync(marker)) return null;
6233
6325
  writeFileSync(marker, '', { flag: 'w' });
6234
6326
  } catch {}
6235
- return '[synkro] No .synkro config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6327
+ return '[synkro] No synkro.toml config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6236
6328
  }
6237
6329
 
6238
6330
  function filePathFromToolInput(ti: any): string {
@@ -6263,13 +6355,13 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6263
6355
  const sessionId = String(payload.session_id || payload.conversation_id || '');
6264
6356
  const root = gitRoot(cwd);
6265
6357
 
6266
- // Dormancy: a repo is onboarded only if it has a .synkro FILE at its git
6358
+ // Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
6267
6359
  // root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
6268
- // this a home-rooted cwd (dotfiles in git, non-git dir under home) would
6269
- // "find" the config dir and look onboarded.
6360
+ // this a home-rooted cwd (dotfiles in git, non-git dir under home) could
6361
+ // look onboarded.
6270
6362
  let synkroFileText = '';
6271
- if (root && root !== HOME && existsSync(join(root, '.synkro'))) {
6272
- try { synkroFileText = readFileSync(join(root, '.synkro'), 'utf-8'); } catch {}
6363
+ if (root && root !== HOME && existsSync(join(root, 'synkro.toml'))) {
6364
+ try { synkroFileText = readFileSync(join(root, 'synkro.toml'), 'utf-8'); } catch {}
6273
6365
  }
6274
6366
  if (!synkroFileText) {
6275
6367
  if (opts.telemetry) { out(failOpen(harness)); return; }
@@ -6303,16 +6395,32 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6303
6395
  envelope.planText = String(ti.plan || ti.content || payload.plan || '');
6304
6396
  }
6305
6397
 
6306
- const timeoutMs = opts.telemetry ? 6000 : 28000;
6398
+ // bash-followup is telemetry, but it can carry the INLINE "task complete ✓ N/N"
6399
+ // result (the server waits up to 30s for the completion grade), so it needs a
6400
+ // wait above that budget — not the 6s telemetry default — or the stub abandons
6401
+ // the request before the result lands and the completion shows up a hook late.
6402
+ const timeoutMs = surface === 'bash-followup' ? 32000 : (opts.telemetry ? 6000 : 28000);
6307
6403
  const url = 'http://127.0.0.1:' + PORT + '/api/scan/' + surface + (harness === 'cursor' ? '?harness=cursor' : '');
6308
- const resp = await fetch(url, {
6309
- method: 'POST',
6310
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
6311
- body: JSON.stringify(envelope),
6312
- signal: AbortSignal.timeout(timeoutMs),
6313
- });
6314
- const text = await resp.text();
6315
- out(resp.ok ? (text.trim() || failOpen(harness)) : failOpen(harness));
6404
+ const body = JSON.stringify(envelope);
6405
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() };
6406
+ // Telemetry hooks (bash-followup, transcript-sync, session telemetry) IGNORE the
6407
+ // response and the server processes them DETACHED, so delivery RELIABILITY beats
6408
+ // latency: retry a few times with backoff. Right after a server restart the
6409
+ // event loop can be briefly busy / not-yet-listening and a single POST silently
6410
+ // drops the event (this was the followup flakiness). Double-delivery is safe here
6411
+ // the server side is idempotent-ish (completion in-flight guard, dup evidence
6412
+ // is harmless). Blocking hooks make ONE attempt: their response IS the verdict
6413
+ // and a retry would double-grade. Normal case: first attempt wins, no delay.
6414
+ const attempts = opts.telemetry ? 3 : 1;
6415
+ let text = '';
6416
+ for (let i = 0; i < attempts; i++) {
6417
+ try {
6418
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) });
6419
+ if (resp.ok) { text = (await resp.text()).trim(); break; }
6420
+ } catch (e) { /* connection refused / timeout → retry below */ }
6421
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
6422
+ }
6423
+ out(text || failOpen(harness));
6316
6424
  } catch {
6317
6425
  out(failOpen(harness));
6318
6426
  }
@@ -6325,11 +6433,30 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6325
6433
  STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6326
6434
  STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6327
6435
  STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6328
- STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6436
+ STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true }");
6329
6437
  STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
6330
6438
  STUB_TRANSCRIPT_SYNC_TS = stubHook("transcript-sync", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6331
6439
  STUB_USER_PROMPT_SUBMIT_TS = stubHook("prompt-submit", "{ telemetry: true }");
6332
6440
  STUB_BASH_FOLLOWUP_TS = stubHook("bash-followup", "{ telemetry: true }");
6441
+ STUB_TASK_ACTIVATE_INTENT_TS = `#!/usr/bin/env bun
6442
+ import { readFileSync } from 'node:fs';
6443
+ import { homedir } from 'node:os';
6444
+ import { join } from 'node:path';
6445
+ const PORT = process.env.SYNKRO_MCP_PORT || '18931';
6446
+ function mcpJwt(): string { try { return readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch { return ''; } }
6447
+ const chunks: Buffer[] = [];
6448
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6449
+ try {
6450
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}');
6451
+ await fetch('http://127.0.0.1:' + PORT + '/api/local/task-activate-intent', {
6452
+ method: 'POST',
6453
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpJwt() },
6454
+ body: JSON.stringify({ session_id: payload.session_id, tool_input: payload.tool_input }),
6455
+ signal: AbortSignal.timeout(5000),
6456
+ });
6457
+ } catch {}
6458
+ process.stdout.write('{}\\n');
6459
+ `;
6333
6460
  STUB_CURSOR_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true, harness: 'cursor' }");
6334
6461
  STUB_CURSOR_EDIT_CAPTURE_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true, harness: 'cursor' }");
6335
6462
  STUB_CURSOR_AGENT_CAPTURE_TS = stubHook("agent-judge", "{ needsTranscript: true, harness: 'cursor' }");
@@ -7230,6 +7357,510 @@ var init_promptFetcher = __esm({
7230
7357
  }
7231
7358
  });
7232
7359
 
7360
+ // cli/installer/claudeDesktopTap.ts
7361
+ var claudeDesktopTap_exports = {};
7362
+ __export(claudeDesktopTap_exports, {
7363
+ claudeDesktopInstalled: () => claudeDesktopInstalled,
7364
+ runClaudeDesktopTap: () => runClaudeDesktopTap
7365
+ });
7366
+ import { spawn } from "child_process";
7367
+ import { writeFileSync as writeFileSync6, mkdtempSync, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
7368
+ import { join as join5 } from "path";
7369
+ import { tmpdir, homedir as homedir5 } from "os";
7370
+ function claudeDesktopInstalled() {
7371
+ return process.platform === "darwin" && existsSync7("/Applications/Claude.app/Contents/MacOS/Claude");
7372
+ }
7373
+ function buildRunner(sessionDir) {
7374
+ return `#!/bin/bash
7375
+ set -e
7376
+ SESSION_DIR=${JSON.stringify(sessionDir)}
7377
+ PROXY_PORT=8080
7378
+ MITM_CA_DIR="$SESSION_DIR/ca"
7379
+ TAP_SCRIPT="$SESSION_DIR/tap.py"
7380
+ CA_FINGERPRINT=""
7381
+ MITM_PID=""
7382
+ SUDO_KEEPALIVE_PID=""
7383
+ MCP_PROXY="$SESSION_DIR/mcp_proxy.py"
7384
+ MCP_PATCH="$SESSION_DIR/mcp_patch.py"
7385
+ CLAUDE_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
7386
+ CLAUDE_CONFIG_BACKUP="$SESSION_DIR/claude_desktop_config.backup.json"
7387
+ MCP_PATCHED=false
7388
+
7389
+ cleanup() {
7390
+ echo ""
7391
+ echo "Stopping Claude Desktop observability..."
7392
+ [ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null
7393
+ [ -n "$SUDO_KEEPALIVE_PID" ] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null
7394
+ # Restore the original MCP config (unwrap the proxy) on exit.
7395
+ if [ "$MCP_PATCHED" = "true" ] && [ -f "$CLAUDE_CONFIG_BACKUP" ]; then
7396
+ cp "$CLAUDE_CONFIG_BACKUP" "$CLAUDE_CONFIG" && echo " MCP config restored"
7397
+ fi
7398
+ if [ -n "$CA_FINGERPRINT" ]; then
7399
+ # Non-interactive (-n) so teardown can NEVER hang on a sudo password prompt
7400
+ # (which would swallow the user's Ctrl+C). The keepalive below keeps creds
7401
+ # warm so this normally succeeds without a prompt.
7402
+ if sudo -n security delete-certificate -Z "$CA_FINGERPRINT" -t /Library/Keychains/System.keychain 2>/dev/null; then
7403
+ echo " Session CA removed from keychain"
7404
+ else
7405
+ echo " Could not auto-remove the session CA (sudo timed out)."
7406
+ echo " Remove it manually: sudo security delete-certificate -c mitmproxy /Library/Keychains/System.keychain"
7407
+ fi
7408
+ fi
7409
+ echo "Done."
7410
+ exit 0
7411
+ }
7412
+ trap cleanup INT TERM EXIT
7413
+
7414
+ if ! command -v mitmdump &>/dev/null; then
7415
+ echo "Installing mitmproxy..."
7416
+ if command -v brew &>/dev/null; then brew install mitmproxy; else echo "mitmproxy required: brew install mitmproxy"; exit 1; fi
7417
+ fi
7418
+
7419
+ mkdir -p "$MITM_CA_DIR"
7420
+ echo "Generating session CA certificate..."
7421
+ mitmdump --set confdir="$MITM_CA_DIR" --listen-port 0 </dev/null &>/dev/null &
7422
+ TMPPID=$!; sleep 2; kill $TMPPID 2>/dev/null || true
7423
+
7424
+ echo "Trusting session CA (sudo \u2014 removed automatically on exit)..."
7425
+ sudo security add-trusted-cert -d -r trustRoot -k "/Library/Keychains/System.keychain" "$MITM_CA_DIR/mitmproxy-ca-cert.pem"
7426
+ CA_FINGERPRINT=$(openssl x509 -in "$MITM_CA_DIR/mitmproxy-ca-cert.pem" -noout -fingerprint -sha1 2>/dev/null | sed "s/.*=//;s/://g")
7427
+
7428
+ # Keep the sudo timestamp warm for the life of the session so teardown's CA
7429
+ # removal never blocks on a password prompt (a prompt would eat Ctrl+C). Exits
7430
+ # itself if creds are ever revoked. Killed in cleanup().
7431
+ ( while true; do sudo -n true 2>/dev/null || exit 0; sleep 30; done ) &
7432
+ SUDO_KEEPALIVE_PID=$!
7433
+
7434
+ lsof -ti:$PROXY_PORT 2>/dev/null | xargs kill 2>/dev/null || true
7435
+ osascript -e 'tell app "Claude" to quit' 2>/dev/null || true
7436
+ sleep 1
7437
+
7438
+ # Patch Claude Desktop's MCP config to wrap each server with the Synkro stdio
7439
+ # proxy (restored on exit). Capture URL + token come from the inherited env.
7440
+ if [ -f "$CLAUDE_CONFIG" ]; then
7441
+ cp "$CLAUDE_CONFIG" "$CLAUDE_CONFIG_BACKUP"
7442
+ if python3 "$MCP_PATCH" "$CLAUDE_CONFIG" "$MCP_PROXY" "$SYNKRO_CAPTURE_URL" "$SYNKRO_TAP_TOKEN" 2>/dev/null; then
7443
+ MCP_PATCHED=true
7444
+ echo "MCP servers wrapped with Synkro proxy"
7445
+ else
7446
+ echo "No MCP servers configured \u2014 skipping MCP proxy"
7447
+ cp "$CLAUDE_CONFIG_BACKUP" "$CLAUDE_CONFIG" 2>/dev/null || true
7448
+ fi
7449
+ fi
7450
+
7451
+ echo "Starting proxy on :$PROXY_PORT..."
7452
+ # stdin from /dev/null so mitmdump can't grab the TTY in raw mode \u2014 otherwise it
7453
+ # captures keypresses (incl. Ctrl+C) instead of letting them become a signal.
7454
+ mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null &>/tmp/synkro-mitm.log &
7455
+ MITM_PID=$!
7456
+ sleep 2
7457
+
7458
+ CLAUDE_BIN=""
7459
+ for p in "/Applications/Claude.app/Contents/MacOS/Claude" "/Volumes/Claude/Claude.app/Contents/MacOS/Claude"; do
7460
+ [ -f "$p" ] && CLAUDE_BIN="$p" && break
7461
+ done
7462
+ if [ -z "$CLAUDE_BIN" ]; then
7463
+ CLAUDE_BIN="$(mdfind 'kMDItemFSName == "Claude.app"' 2>/dev/null | head -1)/Contents/MacOS/Claude"
7464
+ fi
7465
+ if [ ! -f "$CLAUDE_BIN" ]; then echo "Claude Desktop not found."; exit 1; fi
7466
+
7467
+ echo "Launching Claude Desktop..."
7468
+ "$CLAUDE_BIN" --proxy-server="http://localhost:$PROXY_PORT" &>/dev/null &
7469
+
7470
+ echo ""
7471
+ echo " \u2713 Claude Desktop is being monitored by Synkro (local)."
7472
+ echo " Your conversations will appear in the dashboard Events page."
7473
+ echo " Press Ctrl+C to stop (this removes the session CA cleanly)."
7474
+ echo ""
7475
+ wait $MITM_PID
7476
+ `;
7477
+ }
7478
+ async function runClaudeDesktopTap() {
7479
+ if (process.platform !== "darwin") {
7480
+ console.log(" Claude Desktop observability is macOS-only \u2014 skipped.");
7481
+ return;
7482
+ }
7483
+ let token = "";
7484
+ try {
7485
+ token = readFileSync5(JWT_PATH, "utf-8").trim();
7486
+ } catch {
7487
+ }
7488
+ if (!token) {
7489
+ console.log(" Could not read local auth token (~/.synkro/.mcp-jwt). Run `synkro install` first.");
7490
+ return;
7491
+ }
7492
+ const sessionDir = mkdtempSync(join5(tmpdir(), "synkro-cd-"));
7493
+ writeFileSync6(join5(sessionDir, "tap.py"), ADDON_PY, "utf-8");
7494
+ writeFileSync6(join5(sessionDir, "mcp_proxy.py"), MCP_PROXY_PY, { mode: 493 });
7495
+ writeFileSync6(join5(sessionDir, "mcp_patch.py"), MCP_PATCH_PY, "utf-8");
7496
+ const runnerPath = join5(sessionDir, "run.sh");
7497
+ writeFileSync6(runnerPath, buildRunner(sessionDir), { mode: 493 });
7498
+ await new Promise((resolve4) => {
7499
+ const child = spawn("bash", [runnerPath], {
7500
+ stdio: "inherit",
7501
+ env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_TAP_TOKEN: token }
7502
+ });
7503
+ const forward = (sig) => {
7504
+ try {
7505
+ child.kill(sig);
7506
+ } catch {
7507
+ }
7508
+ };
7509
+ process.on("SIGINT", forward);
7510
+ process.on("SIGTERM", forward);
7511
+ child.on("exit", () => {
7512
+ process.off("SIGINT", forward);
7513
+ process.off("SIGTERM", forward);
7514
+ resolve4();
7515
+ });
7516
+ });
7517
+ }
7518
+ var CAPTURE_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7519
+ var init_claudeDesktopTap = __esm({
7520
+ "cli/installer/claudeDesktopTap.ts"() {
7521
+ "use strict";
7522
+ CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
7523
+ JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7524
+ ADDON_PY = `import os, json, threading, urllib.request, re
7525
+ from mitmproxy import http
7526
+
7527
+ CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7528
+ TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7529
+
7530
+ # Governance signals parsed from the completion SSE (usage window + web-search
7531
+ # sources), keyed by conversation id. The completion POST always precedes the
7532
+ # tree GET for the same turn, so the tree capture below reads + clears this.
7533
+ # Guarded by a lock \u2014 tap callbacks can run concurrently across flows (CWE-362).
7534
+ _sse_meta = {}
7535
+ _sse_lock = threading.Lock()
7536
+ # Session-global MCP server registry (uuid -> {name,url,connected,tools}), parsed
7537
+ # from the mcp/v2/bootstrap SSE. All access guarded by _mcp_lock (CWE-362).
7538
+ _mcp_registry = {}
7539
+ _mcp_lock = threading.Lock()
7540
+
7541
+ class SynkroTap:
7542
+ def responseheaders(self, flow):
7543
+ # Buffer the small conversation-tree GET (parsed for the dashboard).
7544
+ # For the completion POST, TAP the SSE chunk-by-chunk and pass each
7545
+ # through unchanged \u2014 Claude Desktop keeps streaming normally while we
7546
+ # accumulate a bounded copy for the raw diagnostic dump. Everything else
7547
+ # streams untouched.
7548
+ r = flow.request
7549
+ if "claude.ai" in (r.pretty_host or "") and "chat_conversations" in r.path:
7550
+ if r.method == "GET" and "tree" in r.pretty_url:
7551
+ return
7552
+ if r.method == "POST" and "completion" in r.path:
7553
+ cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7554
+ cid = cm.group(1) if cm else ""
7555
+ # Incremental parse: scan complete SSE lines for the small governance
7556
+ # signals AS THEY STREAM and keep only the extracted results \u2014 never
7557
+ # buffer the body. The carry holds at most one partial line (bounded),
7558
+ # so there's NO cap on total stream length and no source is dropped.
7559
+ st = {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7560
+ def _scan(line, _st=st):
7561
+ if _st["usage"] is None:
7562
+ um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7563
+ if um:
7564
+ _st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7565
+ if len(_st["sources"]) < 50:
7566
+ for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7567
+ d = sm.group(1)
7568
+ if d and d not in _st["seen"] and len(_st["sources"]) < 50:
7569
+ _st["seen"].add(d)
7570
+ _st["sources"].append({"domain": d, "name": sm.group(2)})
7571
+ # Citations: the specific pages the model cited (title + url +
7572
+ # domain) \u2014 the real "tool result" for web search.
7573
+ if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
7574
+ try:
7575
+ cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7576
+ url = cit.get("url", "")
7577
+ if url and url not in _st["cseen"]:
7578
+ _st["cseen"].add(url)
7579
+ md = cit.get("metadata") or {}
7580
+ _st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7581
+ except Exception:
7582
+ pass
7583
+ def _tap(data, _st=st, _cid=cid):
7584
+ if data:
7585
+ try:
7586
+ txt = _st["carry"] + data.decode("utf-8", "replace")
7587
+ except Exception:
7588
+ txt = _st["carry"]
7589
+ lines = txt.split("\\n")
7590
+ _st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
7591
+ for ln in lines[:-1]:
7592
+ _scan(ln)
7593
+ else: # end of stream \u2014 flush carry + publish
7594
+ _scan(_st["carry"])
7595
+ if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
7596
+ with _sse_lock:
7597
+ _sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
7598
+ return data
7599
+ flow.response.stream = _tap
7600
+ return
7601
+ if "claude.ai" in (r.pretty_host or "") and "mcp" in r.path and "bootstrap" in r.path:
7602
+ return # buffer the MCP registry bootstrap (parsed in response())
7603
+ flow.response.stream = True
7604
+
7605
+ def response(self, flow):
7606
+ r = flow.request
7607
+ if "claude.ai" not in (r.pretty_host or ""): return
7608
+ # MCP server registry from the bootstrap SSE stream.
7609
+ if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7610
+ try:
7611
+ servers = {}
7612
+ for line in (flow.response.get_text() or "").split("\\n"):
7613
+ if not line.startswith("data: "): continue
7614
+ try: dd = json.loads(line[6:])
7615
+ except: continue
7616
+ if isinstance(dd.get("servers"), list):
7617
+ for s in dd["servers"]:
7618
+ sid = s.get("uuid", "")
7619
+ if sid: servers[sid] = {"name": s.get("name", ""), "url": s.get("url", ""), "connected": False, "tools": []}
7620
+ if "uuid" in dd and "connected" in dd and dd.get("uuid") in servers:
7621
+ servers[dd["uuid"]]["connected"] = dd.get("connected", False)
7622
+ if "server_uuid" in dd and isinstance(dd.get("tools"), list) and dd.get("server_uuid") in servers:
7623
+ for t in dd["tools"]:
7624
+ servers[dd["server_uuid"]]["tools"].append({"name": t.get("name", ""), "description": (t.get("description") or "")[:200]})
7625
+ if servers:
7626
+ with _mcp_lock:
7627
+ _mcp_registry.clear()
7628
+ _mcp_registry.update(servers)
7629
+ print("[synkro] MCP servers: " + str(len(servers)))
7630
+ except Exception:
7631
+ pass
7632
+ return
7633
+ if not ("chat_conversations" in r.path and r.method == "GET" and "tree" in r.pretty_url and flow.response): return
7634
+ if not CAPTURE_URL or not TOKEN: return
7635
+ try: convo = json.loads(flow.response.get_text())
7636
+ except: return
7637
+ m = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7638
+ convo_id = m.group(1) if m else ""
7639
+ model = convo.get("model", "unknown")
7640
+ settings = convo.get("settings") or {}
7641
+ emt = settings.get("enabled_mcp_tools")
7642
+ enabled_mcp = list(emt.keys()) if isinstance(emt, dict) else []
7643
+ web_search_on = bool(settings.get("enabled_web_search", False))
7644
+ platform = convo.get("platform", "")
7645
+ all_msgs = convo.get("chat_messages", [])
7646
+ if not all_msgs: return
7647
+ # Current turn only: last human message and everything after it.
7648
+ last_human = -1
7649
+ for i in range(len(all_msgs) - 1, -1, -1):
7650
+ if all_msgs[i].get("sender") == "human": last_human = i; break
7651
+ chat = all_msgs[last_human:] if last_human >= 0 else all_msgs[-2:]
7652
+ messages = []
7653
+ for msg in chat:
7654
+ sender = msg.get("sender", "")
7655
+ role = "user" if sender == "human" else "assistant"
7656
+ blocks = msg.get("content", [])
7657
+ text_parts = []
7658
+ tool_calls = []
7659
+ tool_results = []
7660
+ thinking = ""
7661
+ if isinstance(blocks, list):
7662
+ for b in blocks:
7663
+ if isinstance(b, str): text_parts.append(b); continue
7664
+ if not isinstance(b, dict): continue
7665
+ bt = b.get("type", "")
7666
+ if bt == "text":
7667
+ text_parts.append(b.get("text", ""))
7668
+ elif bt == "thinking":
7669
+ thinking += b.get("thinking", "") or b.get("text", "")
7670
+ elif bt == "tool_use" or bt == "server_tool_use":
7671
+ tool_calls.append({"name": b.get("name", ""), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
7672
+ elif bt.endswith("_tool_result") or bt == "tool_result":
7673
+ rc = b.get("content", "")
7674
+ if isinstance(rc, list):
7675
+ rparts = []
7676
+ for x in rc:
7677
+ if not isinstance(x, dict): continue
7678
+ if x.get("type") == "web_search_result":
7679
+ sn = x.get("page_content", "") or x.get("snippet", "") or x.get("content", "") or ""
7680
+ if isinstance(sn, dict): sn = sn.get("text", "")
7681
+ rparts.append((x.get("title", "") + " (" + x.get("url", "") + ")" + ((" - " + str(sn)[:200]) if sn else "")).strip())
7682
+ elif x.get("text"):
7683
+ rparts.append(x["text"])
7684
+ elif isinstance(x.get("content"), str):
7685
+ rparts.append(x["content"])
7686
+ rc = " | ".join(p for p in rparts if p and p.strip())
7687
+ dc = b.get("display_content")
7688
+ if isinstance(dc, dict):
7689
+ rc = rc or dc.get("json_block", "") or dc.get("text", "")
7690
+ # web_search results are server-side (content:[]); we surface the
7691
+ # sources via sse_meta instead. Only record a non-empty result.
7692
+ if rc and str(rc).strip():
7693
+ tool_results.append({"name": b.get("name", ""), "content": str(rc)})
7694
+ elif isinstance(blocks, str):
7695
+ text_parts.append(blocks)
7696
+ text = " ".join(p for p in text_parts if p).strip()
7697
+ if role == "assistant":
7698
+ am = {"role": "assistant", "content": text}
7699
+ if tool_calls: am["tool_calls"] = tool_calls
7700
+ if thinking: am["thinking"] = thinking
7701
+ if text or tool_calls or thinking: messages.append(am)
7702
+ for tr in tool_results:
7703
+ messages.append({"role": "tool", "content": tr["content"], "name": tr.get("name", "")})
7704
+ elif role == "user" and text:
7705
+ messages.append({"role": "user", "content": text})
7706
+ if not messages: return
7707
+ data = {"request": {"model": model, "messages": messages}, "conversation_id": convo_id}
7708
+ meta = {}
7709
+ if platform: meta["platform"] = platform
7710
+ if web_search_on: meta["web_search_enabled"] = True
7711
+ if enabled_mcp: meta["enabled_mcp_tools"] = enabled_mcp[:20]
7712
+ if meta: data["conversation_metadata"] = meta
7713
+ with _sse_lock:
7714
+ sm = _sse_meta.pop(convo_id, None)
7715
+ if sm: data["sse_meta"] = sm
7716
+ with _mcp_lock:
7717
+ reg = list(_mcp_registry.values())
7718
+ if reg: data["mcp_servers"] = reg
7719
+ ntools = 0
7720
+ for mm in messages:
7721
+ ntools += len(mm.get("tool_calls") or [])
7722
+ nsrc = len((sm or {}).get("sources") or [])
7723
+ print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7724
+ def send(_d=data):
7725
+ try:
7726
+ req = urllib.request.Request(CAPTURE_URL, data=json.dumps(_d).encode(), headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7727
+ urllib.request.urlopen(req, timeout=5)
7728
+ except Exception as e:
7729
+ print("[synkro] capture error: " + str(e))
7730
+ threading.Thread(target=send, daemon=True).start()
7731
+
7732
+ addons = [SynkroTap()]
7733
+ `;
7734
+ MCP_PROXY_PY = `#!/usr/bin/env python3
7735
+ """Synkro MCP Proxy (local) \u2014 stdio passthrough that taps MCP tool calls."""
7736
+ import sys, os, json, subprocess, threading, urllib.request, time
7737
+
7738
+ CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7739
+ TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7740
+ SERVER_NAME = os.environ.get("SYNKRO_MCP_SERVER", "unknown")
7741
+
7742
+ pending = {}
7743
+ pending_lock = threading.Lock()
7744
+
7745
+ def send_capture(tool_name, tool_args, tool_result, latency_ms):
7746
+ if not CAPTURE_URL or not TOKEN: return
7747
+ try:
7748
+ payload = json.dumps({
7749
+ "request": {
7750
+ "model": "mcp:" + SERVER_NAME,
7751
+ "messages": [
7752
+ {"role": "assistant", "content": "", "tool_calls": [{"name": tool_name, "arguments": json.dumps(tool_args)}]},
7753
+ {"role": "tool", "content": json.dumps(tool_result), "name": tool_name},
7754
+ ]
7755
+ },
7756
+ "mcp": {"server": SERVER_NAME, "tool": tool_name, "latency_ms": latency_ms}
7757
+ }).encode()
7758
+ req = urllib.request.Request(CAPTURE_URL, data=payload, headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7759
+ urllib.request.urlopen(req, timeout=5)
7760
+ except Exception as e:
7761
+ print("[synkro-mcp] capture error: " + str(e), file=sys.stderr)
7762
+
7763
+ def tap_message(data, direction):
7764
+ if not isinstance(data, dict): return
7765
+ method = data.get("method")
7766
+ msg_id = data.get("id")
7767
+ if direction == "request" and method == "tools/call":
7768
+ params = data.get("params", {})
7769
+ tool_name = params.get("name", "unknown")
7770
+ tool_args = params.get("arguments", {})
7771
+ if msg_id is not None:
7772
+ with pending_lock:
7773
+ if len(pending) < 1000:
7774
+ pending[msg_id] = {"name": tool_name, "args": tool_args, "start": time.time()}
7775
+ print("[synkro-mcp] " + SERVER_NAME + "." + str(tool_name), file=sys.stderr)
7776
+ if direction == "response" and msg_id is not None:
7777
+ with pending_lock:
7778
+ call = pending.pop(msg_id, None)
7779
+ if call:
7780
+ latency_ms = int((time.time() - call["start"]) * 1000)
7781
+ result = data.get("result", data.get("error", {}))
7782
+ threading.Thread(target=send_capture, args=(call["name"], call["args"], result, latency_ms), daemon=True).start()
7783
+
7784
+ def read_jsonrpc(stream):
7785
+ buf = b""
7786
+ while True:
7787
+ chunk = stream.read(1)
7788
+ if not chunk: return None
7789
+ if len(buf) < 5000000: # bound the line buffer (CWE-400)
7790
+ buf += chunk
7791
+ if chunk == b"\\n":
7792
+ line = buf.strip()
7793
+ buf = b""
7794
+ if not line: continue
7795
+ try: return json.loads(line)
7796
+ except: continue
7797
+
7798
+ def forward_stdin(proc):
7799
+ while True:
7800
+ msg = read_jsonrpc(sys.stdin.buffer)
7801
+ if msg is None: break
7802
+ tap_message(msg, "request")
7803
+ line = json.dumps(msg) + "\\n"
7804
+ try:
7805
+ proc.stdin.write(line.encode())
7806
+ proc.stdin.flush()
7807
+ except: break
7808
+
7809
+ def main():
7810
+ args = sys.argv[1:]
7811
+ if "--" in args:
7812
+ sep = args.index("--")
7813
+ args = args[sep + 1:]
7814
+ if not args:
7815
+ print("Usage: mcp_proxy.py -- <command> [args...]", file=sys.stderr)
7816
+ sys.exit(1)
7817
+ # Spawn the user's own configured MCP server (from claude_desktop_config.json).
7818
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr)
7819
+ t = threading.Thread(target=forward_stdin, args=(proc,), daemon=True)
7820
+ t.start()
7821
+ while True:
7822
+ msg = read_jsonrpc(proc.stdout)
7823
+ if msg is None: break
7824
+ tap_message(msg, "response")
7825
+ line = json.dumps(msg) + "\\n"
7826
+ try:
7827
+ sys.stdout.buffer.write(line.encode())
7828
+ sys.stdout.buffer.flush()
7829
+ except: break
7830
+ proc.wait()
7831
+ sys.exit(proc.returncode)
7832
+
7833
+ if __name__ == "__main__":
7834
+ main()
7835
+ `;
7836
+ MCP_PATCH_PY = `import json, sys
7837
+ config_path, proxy_path, capture_url, token = sys.argv[1:5]
7838
+ with open(config_path) as f: config = json.load(f)
7839
+ servers = config.get("mcpServers", {})
7840
+ if not servers:
7841
+ print("No MCP servers configured")
7842
+ sys.exit(1)
7843
+ patched = 0
7844
+ for name, server in servers.items():
7845
+ if server.get("_synkro_original"): continue
7846
+ orig_cmd = server.get("command", "")
7847
+ orig_args = server.get("args", [])
7848
+ if not orig_cmd: continue
7849
+ server["_synkro_original"] = {"command": orig_cmd, "args": orig_args}
7850
+ server["command"] = "python3"
7851
+ server["args"] = [proxy_path, "--", orig_cmd] + orig_args
7852
+ env = server.get("env", {})
7853
+ env["SYNKRO_CAPTURE_URL"] = capture_url
7854
+ env["SYNKRO_TAP_TOKEN"] = token
7855
+ env["SYNKRO_MCP_SERVER"] = name
7856
+ server["env"] = env
7857
+ patched += 1
7858
+ with open(config_path, "w") as f: json.dump(config, f, indent=2)
7859
+ print("Patched " + str(patched) + " MCP servers")
7860
+ `;
7861
+ }
7862
+ });
7863
+
7233
7864
  // cli/local-cc/macKeychain.ts
7234
7865
  var macKeychain_exports = {};
7235
7866
  __export(macKeychain_exports, {
@@ -7252,9 +7883,9 @@ __export(macKeychain_exports, {
7252
7883
  writeCursorApiKey: () => writeCursorApiKey,
7253
7884
  writeRefreshAgent: () => writeRefreshAgent
7254
7885
  });
7255
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync5, statSync } from "fs";
7256
- import { homedir as homedir5, platform as platform2 } from "os";
7257
- import { join as join5 } from "path";
7886
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
7887
+ import { homedir as homedir6, platform as platform2 } from "os";
7888
+ import { join as join6 } from "path";
7258
7889
  import { spawnSync } from "child_process";
7259
7890
  function needsKeychainBridge() {
7260
7891
  return platform2() === "darwin";
@@ -7274,13 +7905,13 @@ function exportKeychainCreds() {
7274
7905
  if (!blob) return null;
7275
7906
  mkdirSync6(CLAUDE_CREDS_DIR, { recursive: true });
7276
7907
  chmodSync(CLAUDE_CREDS_DIR, 448);
7277
- writeFileSync6(CLAUDE_CREDS_FILE, blob, "utf-8");
7908
+ writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
7278
7909
  chmodSync(CLAUDE_CREDS_FILE, 384);
7279
7910
  return CLAUDE_CREDS_FILE;
7280
7911
  }
7281
7912
  function cursorApiKeyConfigured() {
7282
7913
  try {
7283
- return existsSync7(CURSOR_API_KEY_FILE) && readFileSync5(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
7914
+ return existsSync8(CURSOR_API_KEY_FILE) && readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
7284
7915
  } catch {
7285
7916
  return false;
7286
7917
  }
@@ -7290,13 +7921,13 @@ function writeCursorApiKey(key) {
7290
7921
  if (!trimmed) return;
7291
7922
  mkdirSync6(CURSOR_CREDS_DIR, { recursive: true });
7292
7923
  chmodSync(CURSOR_CREDS_DIR, 448);
7293
- writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
7924
+ writeFileSync7(CURSOR_API_KEY_FILE, trimmed, "utf-8");
7294
7925
  chmodSync(CURSOR_API_KEY_FILE, 384);
7295
7926
  }
7296
7927
  async function validateCursorApiKey() {
7297
7928
  let key;
7298
7929
  try {
7299
- key = readFileSync5(CURSOR_API_KEY_FILE, "utf-8").trim();
7930
+ key = readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim();
7300
7931
  } catch {
7301
7932
  return null;
7302
7933
  }
@@ -7315,7 +7946,7 @@ async function validateCursorApiKey() {
7315
7946
  }
7316
7947
  }
7317
7948
  function credsAreStale() {
7318
- if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
7949
+ if (!existsSync8(CLAUDE_CREDS_FILE)) return true;
7319
7950
  try {
7320
7951
  const ageMs = Date.now() - statSync(CLAUDE_CREDS_FILE).mtimeMs;
7321
7952
  return ageMs > REFRESH_INTERVAL_SECONDS * 1e3;
@@ -7327,7 +7958,7 @@ function writeRefreshAgent(synkroBinPath) {
7327
7958
  if (platform2() !== "darwin") {
7328
7959
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
7329
7960
  }
7330
- mkdirSync6(join5(homedir5(), "Library", "LaunchAgents"), { recursive: true });
7961
+ mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
7331
7962
  const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
7332
7963
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
7333
7964
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -7346,13 +7977,13 @@ function writeRefreshAgent(synkroBinPath) {
7346
7977
  <key>RunAtLoad</key>
7347
7978
  <true/>
7348
7979
  <key>StandardErrorPath</key>
7349
- <string>${join5(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7980
+ <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7350
7981
  <key>StandardOutPath</key>
7351
- <string>${join5(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7982
+ <string>${join6(SYNKRO_DIR, "claude-creds-refresh.log")}</string>
7352
7983
  </dict>
7353
7984
  </plist>
7354
7985
  `;
7355
- writeFileSync6(LAUNCHD_PLIST, plist, "utf-8");
7986
+ writeFileSync7(LAUNCHD_PLIST, plist, "utf-8");
7356
7987
  return LAUNCHD_PLIST;
7357
7988
  }
7358
7989
  function loadRefreshAgent() {
@@ -7378,7 +8009,7 @@ function uninstallRefreshAgent() {
7378
8009
  timeout: 5e3
7379
8010
  });
7380
8011
  try {
7381
- if (existsSync7(LAUNCHD_PLIST)) {
8012
+ if (existsSync8(LAUNCHD_PLIST)) {
7382
8013
  __require("fs").unlinkSync(LAUNCHD_PLIST);
7383
8014
  }
7384
8015
  } catch {
@@ -7390,7 +8021,7 @@ function refreshCreds() {
7390
8021
  }
7391
8022
  function readExportedCreds() {
7392
8023
  try {
7393
- return readFileSync5(CLAUDE_CREDS_FILE, "utf-8");
8024
+ return readFileSync6(CLAUDE_CREDS_FILE, "utf-8");
7394
8025
  } catch {
7395
8026
  return null;
7396
8027
  }
@@ -7399,14 +8030,14 @@ var SYNKRO_DIR, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, CURSOR_CREDS_DIR, CURSOR_AP
7399
8030
  var init_macKeychain = __esm({
7400
8031
  "cli/local-cc/macKeychain.ts"() {
7401
8032
  "use strict";
7402
- SYNKRO_DIR = join5(homedir5(), ".synkro");
7403
- CLAUDE_CREDS_DIR = join5(SYNKRO_DIR, "claude-creds");
7404
- CLAUDE_CREDS_FILE = join5(CLAUDE_CREDS_DIR, ".credentials.json");
7405
- CURSOR_CREDS_DIR = join5(SYNKRO_DIR, "cursor-creds");
7406
- CURSOR_API_KEY_FILE = join5(CURSOR_CREDS_DIR, "api-key");
8033
+ SYNKRO_DIR = join6(homedir6(), ".synkro");
8034
+ CLAUDE_CREDS_DIR = join6(SYNKRO_DIR, "claude-creds");
8035
+ CLAUDE_CREDS_FILE = join6(CLAUDE_CREDS_DIR, ".credentials.json");
8036
+ CURSOR_CREDS_DIR = join6(SYNKRO_DIR, "cursor-creds");
8037
+ CURSOR_API_KEY_FILE = join6(CURSOR_CREDS_DIR, "api-key");
7407
8038
  KEYCHAIN_SERVICE = "Claude Code-credentials";
7408
8039
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
7409
- LAUNCHD_PLIST = join5(homedir5(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
8040
+ LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
7410
8041
  REFRESH_INTERVAL_SECONDS = 45 * 60;
7411
8042
  KeychainExportError = class extends Error {
7412
8043
  constructor(message, cause) {
@@ -7443,9 +8074,9 @@ __export(dockerInstall_exports, {
7443
8074
  waitForContainerReady: () => waitForContainerReady,
7444
8075
  waitForWorkersReady: () => waitForWorkersReady
7445
8076
  });
7446
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
7447
- import { homedir as homedir6 } from "os";
7448
- import { join as join6 } from "path";
8077
+ import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
8078
+ import { homedir as homedir7 } from "os";
8079
+ import { join as join7 } from "path";
7449
8080
  import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
7450
8081
  function splitWorkers(total, providers) {
7451
8082
  const t = Math.max(0, Math.floor(total));
@@ -7476,7 +8107,7 @@ function resolveGraderPool(parsed) {
7476
8107
  if (np === "claude_code") counts.claude_code = Math.max(0, Math.floor(v));
7477
8108
  else if (np === "cursor") counts.cursor = Math.max(0, Math.floor(v));
7478
8109
  else if (flagUnknown) {
7479
- warnings.push(`.synkro: ${where}.${k} is not a known provider (use claude-code or cursor) \u2014 ignored`);
8110
+ warnings.push(`synkro.toml: ${where}.${k} is not a known provider (use claude-code or cursor) \u2014 ignored`);
7480
8111
  }
7481
8112
  }
7482
8113
  };
@@ -7487,7 +8118,7 @@ function resolveGraderPool(parsed) {
7487
8118
  if (rawPool != null && rawPool !== "auto") {
7488
8119
  const np = normalizeProvider(String(rawPool));
7489
8120
  if (np) pool = np;
7490
- else warnings.push(`.synkro: grader.pool="${rawPool}" is not recognized (use auto | claude-code | cursor) \u2014 falling back to auto`);
8121
+ else warnings.push(`synkro.toml: grader.pool="${rawPool}" is not recognized (use auto | claude-code | cursor) \u2014 falling back to auto`);
7491
8122
  }
7492
8123
  if (counts.claude_code != null || counts.cursor != null) {
7493
8124
  return { claudeWorkers: counts.claude_code, cursorWorkers: counts.cursor, pool, warnings };
@@ -7497,57 +8128,52 @@ function resolveGraderPool(parsed) {
7497
8128
  function poolLabel(pool) {
7498
8129
  return pool === "claude_code" ? "claude-code" : pool;
7499
8130
  }
7500
- function parseSynkroYaml(raw) {
8131
+ function parseTomlValue(raw) {
8132
+ const v = raw.trim();
8133
+ if (v.startsWith("[") && v.endsWith("]")) {
8134
+ const inner = v.slice(1, -1).trim();
8135
+ if (!inner) return [];
8136
+ return inner.split(",").map((s) => parseTomlValue(s)).filter((s) => s !== "");
8137
+ }
8138
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
8139
+ if (v === "true") return true;
8140
+ if (v === "false") return false;
8141
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
8142
+ return v;
8143
+ }
8144
+ function parseSynkroToml(raw) {
7501
8145
  const result = {};
7502
- const lines = raw.split("\n");
7503
- let currentKey = "";
7504
- let currentObj = null;
7505
- let currentArr = null;
7506
- for (const line of lines) {
7507
- if (!line.trim() || line.trim().startsWith("#")) continue;
7508
- if (/^\S/.test(line) && line.includes(":")) {
7509
- if (currentObj && currentKey) result[currentKey] = currentObj;
7510
- if (currentArr && currentKey) result[currentKey] = currentArr;
7511
- currentObj = null;
7512
- currentArr = null;
7513
- const ci = line.indexOf(":");
7514
- const key = line.slice(0, ci).trim();
7515
- const val = line.slice(ci + 1).trim();
7516
- currentKey = key;
7517
- if (val) {
7518
- if (val === "[]") result[key] = [];
7519
- else if (val === "true") result[key] = true;
7520
- else if (val === "false") result[key] = false;
7521
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
7522
- else result[key] = val;
7523
- currentKey = "";
7524
- }
7525
- } else if (/^ - /.test(line)) {
7526
- if (!currentArr) currentArr = [];
7527
- currentArr.push(line.replace(/^ - /, "").trim());
7528
- } else if (/^ \S/.test(line) && line.includes(":")) {
7529
- if (!currentObj) currentObj = {};
7530
- const ci = line.indexOf(":");
7531
- const k = line.slice(0, ci).trim();
7532
- const v = line.slice(ci + 1).trim();
7533
- if (v === "true") currentObj[k] = true;
7534
- else if (v === "false") currentObj[k] = false;
7535
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
7536
- else currentObj[k] = v;
7537
- }
7538
- }
7539
- if (currentObj && currentKey) result[currentKey] = currentObj;
7540
- if (currentArr && currentKey) result[currentKey] = currentArr;
8146
+ let section = null;
8147
+ for (const rawLine of raw.split("\n")) {
8148
+ const line = rawLine.trim();
8149
+ if (!line || line.startsWith("#")) continue;
8150
+ const sec = line.match(/^\[([^\]]+)\]$/);
8151
+ if (sec) {
8152
+ section = sec[1].trim();
8153
+ if (!result[section]) result[section] = {};
8154
+ continue;
8155
+ }
8156
+ const eq = line.indexOf("=");
8157
+ if (eq === -1) continue;
8158
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, "");
8159
+ let valRaw = line.slice(eq + 1).trim();
8160
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith("[")) {
8161
+ const h = valRaw.indexOf("#");
8162
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
8163
+ }
8164
+ const value = parseTomlValue(valRaw);
8165
+ if (section) result[section][key] = value;
8166
+ else result[key] = value;
8167
+ }
7541
8168
  return result;
7542
8169
  }
7543
8170
  function readSynkroFileConfig() {
7544
8171
  try {
7545
8172
  const root = execSync4("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
7546
8173
  if (!root) return { pool: "auto", warnings: [] };
7547
- const fp = join6(root, ".synkro");
7548
- if (!existsSync8(fp)) return { pool: "auto", warnings: [] };
7549
- const raw = readFileSync6(fp, "utf-8");
7550
- const parsed = raw.trimStart().startsWith("{") ? JSON.parse(raw) : parseSynkroYaml(raw);
8174
+ const fp = join7(root, "synkro.toml");
8175
+ if (!existsSync9(fp)) return { pool: "auto", warnings: [] };
8176
+ const parsed = parseSynkroToml(readFileSync7(fp, "utf-8"));
7551
8177
  return resolveGraderPool(parsed);
7552
8178
  } catch {
7553
8179
  }
@@ -7622,7 +8248,7 @@ function assertDockerAvailable() {
7622
8248
  }
7623
8249
  function claudeCredsHostDir() {
7624
8250
  if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
7625
- return join6(homedir6(), ".claude");
8251
+ return join7(homedir7(), ".claude");
7626
8252
  }
7627
8253
  function resolveSynkroBin() {
7628
8254
  const which2 = spawnSync2("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
@@ -7678,11 +8304,11 @@ async function dockerInstall(opts = {}) {
7678
8304
  mkdirSync7(PGDATA_PATH, { recursive: true });
7679
8305
  mkdirSync7(BACKUP_DIR, { recursive: true });
7680
8306
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
7681
- const hostClaudeJson = join6(homedir6(), ".claude.json");
7682
- if (existsSync8(hostClaudeJson)) {
8307
+ const hostClaudeJson = join7(homedir7(), ".claude.json");
8308
+ if (existsSync9(hostClaudeJson)) {
7683
8309
  copyFileSync(hostClaudeJson, CLAUDE_HOST_STATE_FILE);
7684
8310
  }
7685
- if (!existsSync8(MCP_JWT_PATH)) {
8311
+ if (!existsSync9(MCP_JWT_PATH)) {
7686
8312
  throw new DockerInstallError(
7687
8313
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
7688
8314
  );
@@ -7710,7 +8336,7 @@ async function dockerInstall(opts = {}) {
7710
8336
  console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
7711
8337
  }
7712
8338
  } else {
7713
- mkdirSync7(join6(homedir6(), ".claude"), { recursive: true });
8339
+ mkdirSync7(join7(homedir7(), ".claude"), { recursive: true });
7714
8340
  }
7715
8341
  const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
7716
8342
  const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
@@ -7763,7 +8389,7 @@ async function dockerInstall(opts = {}) {
7763
8389
  "-v",
7764
8390
  `${credsDir}:/home/synkro/.claude:rw`,
7765
8391
  "-v",
7766
- `${join6(homedir6(), ".claude")}:/data/claude-host:ro`,
8392
+ `${join7(homedir7(), ".claude")}:/data/claude-host:ro`,
7767
8393
  "-v",
7768
8394
  `${CLAUDE_HOST_STATE_DIR}:/data/claude-host-state:ro`,
7769
8395
  // Cursor creds — mounted RW so the in-container refresher can rotate the
@@ -7943,7 +8569,7 @@ async function dockerSafeStart() {
7943
8569
  return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
7944
8570
  }
7945
8571
  const pgCheck = checkPgdata();
7946
- if (existsSync8(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
8572
+ if (existsSync9(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
7947
8573
  if (pgCheck.healthy) {
7948
8574
  console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
7949
8575
  } else {
@@ -7983,7 +8609,7 @@ async function dockerSafeRestart() {
7983
8609
  return { ok: startResult.ok, stop: stopResult, start: startResult };
7984
8610
  }
7985
8611
  function checkPgdata() {
7986
- if (!existsSync8(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
8612
+ if (!existsSync9(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
7987
8613
  const entries = readdirSync2(PGDATA_PATH);
7988
8614
  if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
7989
8615
  const hasPidFile = entries.includes("postmaster.pid");
@@ -8000,11 +8626,11 @@ var init_dockerInstall = __esm({
8000
8626
  "use strict";
8001
8627
  init_agentDetect();
8002
8628
  init_macKeychain();
8003
- SYNKRO_DIR2 = join6(homedir6(), ".synkro");
8004
- MCP_JWT_PATH = join6(SYNKRO_DIR2, ".mcp-jwt");
8005
- PGDATA_PATH = join6(SYNKRO_DIR2, "pgdata");
8006
- CLAUDE_HOST_STATE_DIR = join6(SYNKRO_DIR2, "claude-host-state");
8007
- CLAUDE_HOST_STATE_FILE = join6(CLAUDE_HOST_STATE_DIR, ".claude.json");
8629
+ SYNKRO_DIR2 = join7(homedir7(), ".synkro");
8630
+ MCP_JWT_PATH = join7(SYNKRO_DIR2, ".mcp-jwt");
8631
+ PGDATA_PATH = join7(SYNKRO_DIR2, "pgdata");
8632
+ CLAUDE_HOST_STATE_DIR = join7(SYNKRO_DIR2, "claude-host-state");
8633
+ CLAUDE_HOST_STATE_FILE = join7(CLAUDE_HOST_STATE_DIR, ".claude.json");
8008
8634
  HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
8009
8635
  HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
8010
8636
  HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
@@ -8019,7 +8645,7 @@ var init_dockerInstall = __esm({
8019
8645
  }
8020
8646
  cause;
8021
8647
  };
8022
- BACKUP_DIR = join6(SYNKRO_DIR2, "pgdata-backups");
8648
+ BACKUP_DIR = join7(SYNKRO_DIR2, "pgdata-backups");
8023
8649
  }
8024
8650
  });
8025
8651
 
@@ -8032,14 +8658,14 @@ __export(setupGithub_exports, {
8032
8658
  import { createInterface as createInterface2 } from "readline/promises";
8033
8659
  import { stdin as input, stdout as output } from "process";
8034
8660
  import { execSync as execSync5, spawn as nodeSpawn } from "child_process";
8035
- import { existsSync as existsSync9, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
8036
- import { homedir as homedir7, platform as platform3 } from "os";
8037
- import { join as join7 } from "path";
8661
+ import { existsSync as existsSync10, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
8662
+ import { homedir as homedir8, platform as platform3 } from "os";
8663
+ import { join as join8 } from "path";
8038
8664
  import { execFile as execFile2 } from "child_process";
8039
8665
  function readConfig() {
8040
- if (!existsSync9(CONFIG_PATH)) return {};
8666
+ if (!existsSync10(CONFIG_PATH)) return {};
8041
8667
  const out = {};
8042
- for (const line of readFileSync7(CONFIG_PATH, "utf-8").split("\n")) {
8668
+ for (const line of readFileSync8(CONFIG_PATH, "utf-8").split("\n")) {
8043
8669
  const t = line.trim();
8044
8670
  if (!t || t.startsWith("#")) continue;
8045
8671
  const eq = t.indexOf("=");
@@ -8100,7 +8726,7 @@ function sleep(ms) {
8100
8726
  return new Promise((r) => setTimeout(r, ms));
8101
8727
  }
8102
8728
  function captureClaudeSetupToken() {
8103
- const tmpFile = join7(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
8729
+ const tmpFile = join8(SYNKRO_DIR3, `token-capture-${Date.now()}.raw`);
8104
8730
  return new Promise((resolve4, reject) => {
8105
8731
  const proc = nodeSpawn("script", ["-q", tmpFile, "claude", "setup-token"], {
8106
8732
  stdio: "inherit"
@@ -8109,7 +8735,7 @@ function captureClaudeSetupToken() {
8109
8735
  proc.on("close", (code) => {
8110
8736
  let raw = "";
8111
8737
  try {
8112
- raw = readFileSync7(tmpFile, "utf-8");
8738
+ raw = readFileSync8(tmpFile, "utf-8");
8113
8739
  } catch (e) {
8114
8740
  reject(new Error(`Could not read script output file: ${e.message}`));
8115
8741
  return;
@@ -8381,8 +9007,8 @@ var init_setupGithub = __esm({
8381
9007
  "use strict";
8382
9008
  init_githubSetup();
8383
9009
  init_stub();
8384
- SYNKRO_DIR3 = join7(homedir7(), ".synkro");
8385
- CONFIG_PATH = join7(SYNKRO_DIR3, "config.env");
9010
+ SYNKRO_DIR3 = join8(homedir8(), ".synkro");
9011
+ CONFIG_PATH = join8(SYNKRO_DIR3, "config.env");
8386
9012
  }
8387
9013
  });
8388
9014
 
@@ -8396,16 +9022,16 @@ __export(install_exports, {
8396
9022
  syncSkillFiles: () => syncSkillFiles,
8397
9023
  writeHookScripts: () => writeHookScripts
8398
9024
  });
8399
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
8400
- import { homedir as homedir8 } from "os";
8401
- import { join as join8 } from "path";
9025
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
9026
+ import { homedir as homedir9 } from "os";
9027
+ import { join as join9 } from "path";
8402
9028
  import { execSync as execSync6 } from "child_process";
8403
9029
  import { createInterface as createInterface3 } from "readline";
8404
9030
  function resolvePersistedHookMode() {
8405
9031
  if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
8406
9032
  if (process.env.SYNKRO_HOOK_MODE === "stub") return "stub";
8407
9033
  try {
8408
- const env = readFileSync8(CONFIG_PATH2, "utf-8");
9034
+ const env = readFileSync9(CONFIG_PATH2, "utf-8");
8409
9035
  if (/^SYNKRO_HOOK_MODE=['"]?full['"]?\s*$/m.test(env)) return "full";
8410
9036
  } catch {
8411
9037
  }
@@ -8558,30 +9184,31 @@ function ensureSynkroDir() {
8558
9184
  mkdirSync8(HOOKS_DIR, { recursive: true });
8559
9185
  mkdirSync8(BIN_DIR, { recursive: true });
8560
9186
  mkdirSync8(OFFSETS_DIR, { recursive: true });
8561
- mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
9187
+ mkdirSync8(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
8562
9188
  }
8563
9189
  function writeHookScripts(mode = "stub") {
8564
- const installExtractCorePath = join8(HOOKS_DIR, "installExtractCore.ts");
8565
- const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
8566
- const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
8567
- const editPrecheckScriptPath = join8(HOOKS_DIR, "cc-edit-precheck.ts");
8568
- const cwePrecheckScriptPath = join8(HOOKS_DIR, "cc-cwe-precheck.ts");
8569
- const cvePrecheckScriptPath = join8(HOOKS_DIR, "cc-cve-precheck.ts");
8570
- const planJudgeScriptPath = join8(HOOKS_DIR, "cc-plan-judge.ts");
8571
- const agentJudgeScriptPath = join8(HOOKS_DIR, "cc-agent-judge.ts");
8572
- const stopSummaryScriptPath = join8(HOOKS_DIR, "cc-stop-summary.ts");
8573
- const sessionStartScriptPath = join8(HOOKS_DIR, "cc-session-start.ts");
8574
- const transcriptSyncScriptPath = join8(HOOKS_DIR, "cc-transcript-sync.ts");
8575
- const userPromptSubmitScriptPath = join8(HOOKS_DIR, "cc-user-prompt-submit.ts");
8576
- const commonScriptPath = join8(HOOKS_DIR, "_synkro-common.ts");
8577
- const commonBashScriptPath = join8(HOOKS_DIR, "_synkro-common.sh");
8578
- const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
8579
- const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
8580
- const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8581
- const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8582
- const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
9190
+ const installExtractCorePath = join9(HOOKS_DIR, "installExtractCore.ts");
9191
+ const bashScriptPath = join9(HOOKS_DIR, "cc-bash-judge.ts");
9192
+ const bashFollowupScriptPath = join9(HOOKS_DIR, "cc-bash-followup.ts");
9193
+ const editPrecheckScriptPath = join9(HOOKS_DIR, "cc-edit-precheck.ts");
9194
+ const cwePrecheckScriptPath = join9(HOOKS_DIR, "cc-cwe-precheck.ts");
9195
+ const cvePrecheckScriptPath = join9(HOOKS_DIR, "cc-cve-precheck.ts");
9196
+ const planJudgeScriptPath = join9(HOOKS_DIR, "cc-plan-judge.ts");
9197
+ const agentJudgeScriptPath = join9(HOOKS_DIR, "cc-agent-judge.ts");
9198
+ const stopSummaryScriptPath = join9(HOOKS_DIR, "cc-stop-summary.ts");
9199
+ const sessionStartScriptPath = join9(HOOKS_DIR, "cc-session-start.ts");
9200
+ const transcriptSyncScriptPath = join9(HOOKS_DIR, "cc-transcript-sync.ts");
9201
+ const userPromptSubmitScriptPath = join9(HOOKS_DIR, "cc-user-prompt-submit.ts");
9202
+ const commonScriptPath = join9(HOOKS_DIR, "_synkro-common.ts");
9203
+ const commonBashScriptPath = join9(HOOKS_DIR, "_synkro-common.sh");
9204
+ const installScanScriptPath = join9(HOOKS_DIR, "cc-install-scan.ts");
9205
+ const cursorBashJudgePath = join9(HOOKS_DIR, "cursor-bash-judge.ts");
9206
+ const cursorEditCapturePath = join9(HOOKS_DIR, "cursor-edit-capture.ts");
9207
+ const cursorAgentCapturePath = join9(HOOKS_DIR, "cursor-agent-capture.ts");
9208
+ const mcpStdioProxyPath = join9(HOOKS_DIR, "mcp-stdio-proxy.ts");
9209
+ const taskActivateIntentScriptPath = join9(HOOKS_DIR, "cc-task-activate-intent.ts");
8583
9210
  if (mode === "stub") {
8584
- const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
9211
+ const stubCommonPath = join9(HOOKS_DIR, "_synkro-stub-common.ts");
8585
9212
  const stubFiles = [
8586
9213
  [stubCommonPath, STUB_COMMON_TS],
8587
9214
  [bashScriptPath, STUB_BASH_JUDGE_TS],
@@ -8596,19 +9223,20 @@ function writeHookScripts(mode = "stub") {
8596
9223
  [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8597
9224
  [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8598
9225
  [installScanScriptPath, STUB_INSTALL_SCAN_TS],
9226
+ [taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
8599
9227
  [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8600
9228
  [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8601
9229
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
8602
9230
  ];
8603
9231
  for (const [p, content] of stubFiles) {
8604
- writeFileSync7(p, content, "utf-8");
9232
+ writeFileSync8(p, content, "utf-8");
8605
9233
  chmodSync2(p, 493);
8606
9234
  }
8607
- writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
9235
+ writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8608
9236
  chmodSync2(mcpStdioProxyPath, 493);
8609
9237
  for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
8610
9238
  try {
8611
- unlinkSync4(join8(HOOKS_DIR, stale));
9239
+ unlinkSync4(join9(HOOKS_DIR, stale));
8612
9240
  } catch {
8613
9241
  }
8614
9242
  }
@@ -8627,28 +9255,30 @@ function writeHookScripts(mode = "stub") {
8627
9255
  installScanScript: installScanScriptPath,
8628
9256
  cursorBashJudgeScript: cursorBashJudgePath,
8629
9257
  cursorEditCaptureScript: cursorEditCapturePath,
8630
- cursorAgentCaptureScript: cursorAgentCapturePath
9258
+ cursorAgentCaptureScript: cursorAgentCapturePath,
9259
+ taskActivateIntentScript: taskActivateIntentScriptPath
8631
9260
  };
8632
9261
  }
8633
- writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
8634
- writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
8635
- writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
8636
- writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
8637
- writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
8638
- writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
8639
- writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
8640
- writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
8641
- writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
8642
- writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
8643
- writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
8644
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
8645
- writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
8646
- writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
8647
- writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
8648
- writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
8649
- writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8650
- writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8651
- writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
9262
+ writeFileSync8(bashScriptPath, BASH_JUDGE_TS, "utf-8");
9263
+ writeFileSync8(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
9264
+ writeFileSync8(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
9265
+ writeFileSync8(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
9266
+ writeFileSync8(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
9267
+ writeFileSync8(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
9268
+ writeFileSync8(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
9269
+ writeFileSync8(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
9270
+ writeFileSync8(sessionStartScriptPath, SESSION_START_TS, "utf-8");
9271
+ writeFileSync8(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
9272
+ writeFileSync8(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
9273
+ writeFileSync8(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
9274
+ writeFileSync8(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
9275
+ writeFileSync8(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
9276
+ writeFileSync8(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
9277
+ writeFileSync8(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
9278
+ writeFileSync8(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
9279
+ writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
9280
+ writeFileSync8(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
9281
+ writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
8652
9282
  chmodSync2(bashScriptPath, 493);
8653
9283
  chmodSync2(bashFollowupScriptPath, 493);
8654
9284
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8668,8 +9298,9 @@ function writeHookScripts(mode = "stub") {
8668
9298
  chmodSync2(cursorAgentCapturePath, 493);
8669
9299
  chmodSync2(mcpStdioProxyPath, 493);
8670
9300
  chmodSync2(installExtractCorePath, 493);
9301
+ chmodSync2(taskActivateIntentScriptPath, 493);
8671
9302
  try {
8672
- unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
9303
+ unlinkSync4(join9(HOOKS_DIR, "_synkro-stub-common.ts"));
8673
9304
  } catch {
8674
9305
  }
8675
9306
  return {
@@ -8687,7 +9318,8 @@ function writeHookScripts(mode = "stub") {
8687
9318
  installScanScript: installScanScriptPath,
8688
9319
  cursorBashJudgeScript: cursorBashJudgePath,
8689
9320
  cursorEditCaptureScript: cursorEditCapturePath,
8690
- cursorAgentCaptureScript: cursorAgentCapturePath
9321
+ cursorAgentCaptureScript: cursorAgentCapturePath,
9322
+ taskActivateIntentScript: taskActivateIntentScriptPath
8691
9323
  };
8692
9324
  }
8693
9325
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -8699,11 +9331,11 @@ function shellQuoteSingle(value) {
8699
9331
  }
8700
9332
  function resolveSynkroBundle() {
8701
9333
  const scriptPath = process.argv[1];
8702
- if (scriptPath && existsSync10(scriptPath)) return scriptPath;
9334
+ if (scriptPath && existsSync11(scriptPath)) return scriptPath;
8703
9335
  return null;
8704
9336
  }
8705
9337
  function writeConfigEnv(opts) {
8706
- const credsPath = join8(SYNKRO_DIR4, "credentials.json");
9338
+ const credsPath = join9(SYNKRO_DIR4, "credentials.json");
8707
9339
  const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
8708
9340
  const safeUserId = sanitizeConfigValue(opts.userId);
8709
9341
  const safeOrgId = sanitizeConfigValue(opts.orgId);
@@ -8719,7 +9351,7 @@ function writeConfigEnv(opts) {
8719
9351
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8720
9352
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8721
9353
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8722
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.52")}`
9354
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.54")}`
8723
9355
  ];
8724
9356
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8725
9357
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8735,15 +9367,15 @@ function writeConfigEnv(opts) {
8735
9367
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
8736
9368
  lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "stub", 8))}`);
8737
9369
  lines.push("");
8738
- writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
9370
+ writeFileSync8(CONFIG_PATH2, lines.join("\n"), "utf-8");
8739
9371
  chmodSync2(CONFIG_PATH2, 384);
8740
9372
  }
8741
9373
  function resolveDeploymentMode() {
8742
9374
  const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE?.toLowerCase();
8743
9375
  if (envOverride === "bare-host" || envOverride === "docker") return envOverride;
8744
9376
  try {
8745
- if (existsSync10(CONFIG_PATH2)) {
8746
- const m = readFileSync8(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
9377
+ if (existsSync11(CONFIG_PATH2)) {
9378
+ const m = readFileSync9(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
8747
9379
  const val = m?.[1]?.toLowerCase();
8748
9380
  if (val === "bare-host" || val === "docker") return val;
8749
9381
  }
@@ -8770,16 +9402,16 @@ function collectLocalMetadata(includeClaudeCode = true) {
8770
9402
  meta.cc_version = execSync6("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
8771
9403
  } catch {
8772
9404
  }
8773
- const claudeDir = join8(homedir8(), ".claude");
9405
+ const claudeDir = join9(homedir9(), ".claude");
8774
9406
  try {
8775
- const settings = JSON.parse(readFileSync8(join8(claudeDir, "settings.json"), "utf-8"));
9407
+ const settings = JSON.parse(readFileSync9(join9(claudeDir, "settings.json"), "utf-8"));
8776
9408
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
8777
9409
  if (plugins.length) meta.enabled_plugins = plugins;
8778
9410
  if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
8779
9411
  } catch {
8780
9412
  }
8781
9413
  try {
8782
- const mcpCache = JSON.parse(readFileSync8(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
9414
+ const mcpCache = JSON.parse(readFileSync9(join9(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
8783
9415
  const mcpNames = Object.keys(mcpCache);
8784
9416
  if (mcpNames.length) meta.mcp_servers = mcpNames;
8785
9417
  } catch {
@@ -8791,10 +9423,10 @@ function collectLocalMetadata(includeClaudeCode = true) {
8791
9423
  } catch {
8792
9424
  }
8793
9425
  try {
8794
- const sessionsDir = join8(claudeDir, "sessions");
9426
+ const sessionsDir = join9(claudeDir, "sessions");
8795
9427
  const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
8796
9428
  for (const f of files) {
8797
- const s = JSON.parse(readFileSync8(join8(sessionsDir, f), "utf-8"));
9429
+ const s = JSON.parse(readFileSync9(join9(sessionsDir, f), "utf-8"));
8798
9430
  if (s.version) {
8799
9431
  meta.cc_version = meta.cc_version || s.version;
8800
9432
  break;
@@ -8969,9 +9601,9 @@ async function installCommand(opts = {}) {
8969
9601
  const scripts = writeHookScripts(hookMode);
8970
9602
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
8971
9603
  for (const mode of ["edit", "bash"]) {
8972
- const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
9604
+ const pidFile = join9(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
8973
9605
  try {
8974
- const pid = parseInt(readFileSync8(pidFile, "utf-8").trim(), 10);
9606
+ const pid = parseInt(readFileSync9(pidFile, "utf-8").trim(), 10);
8975
9607
  if (pid > 0) {
8976
9608
  process.kill(pid, "SIGTERM");
8977
9609
  console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
@@ -8997,6 +9629,7 @@ async function installCommand(opts = {}) {
8997
9629
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
8998
9630
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
8999
9631
  installScanScriptPath: scripts.installScanScript,
9632
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
9000
9633
  skipTranscriptSync: !transcriptConsent
9001
9634
  });
9002
9635
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -9050,7 +9683,7 @@ async function installCommand(opts = {}) {
9050
9683
  if (mintResp.ok) {
9051
9684
  const minted = await mintResp.json();
9052
9685
  mcpJwt = minted.token;
9053
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
9686
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
9054
9687
  } else {
9055
9688
  console.warn(" \u26A0 Could not mint MCP token \u2014 local server will reject requests until re-installed.");
9056
9689
  }
@@ -9078,7 +9711,7 @@ async function installCommand(opts = {}) {
9078
9711
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
9079
9712
  }
9080
9713
  const minted = await mintResp.json();
9081
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9714
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9082
9715
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
9083
9716
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
9084
9717
  console.log(` url: ${mcp.url}`);
@@ -9095,8 +9728,8 @@ async function installCommand(opts = {}) {
9095
9728
  if (hasCursor && !opts.noMcp) {
9096
9729
  try {
9097
9730
  if (useLocalMcp) {
9098
- const jwtPath = join8(SYNKRO_DIR4, ".mcp-jwt");
9099
- if (!existsSync10(jwtPath)) {
9731
+ const jwtPath = join9(SYNKRO_DIR4, ".mcp-jwt");
9732
+ if (!existsSync11(jwtPath)) {
9100
9733
  const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
9101
9734
  method: "POST",
9102
9735
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
@@ -9104,7 +9737,7 @@ async function installCommand(opts = {}) {
9104
9737
  });
9105
9738
  if (mintResp.ok) {
9106
9739
  const minted = await mintResp.json();
9107
- writeFileSync7(jwtPath, minted.token + "\n", { mode: 384 });
9740
+ writeFileSync8(jwtPath, minted.token + "\n", { mode: 384 });
9108
9741
  }
9109
9742
  }
9110
9743
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: "", local: true });
@@ -9124,7 +9757,7 @@ async function installCommand(opts = {}) {
9124
9757
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
9125
9758
  }
9126
9759
  const minted = await mintResp.json();
9127
- writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9760
+ writeFileSync8(join9(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
9128
9761
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: minted.token });
9129
9762
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
9130
9763
  console.log(` url: ${mcp.url}`);
@@ -9175,7 +9808,7 @@ async function installCommand(opts = {}) {
9175
9808
  claudeWorkers = 0;
9176
9809
  cursorWorkers = 8;
9177
9810
  }
9178
- console.log(` .synkro: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9811
+ console.log(` synkro.toml: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9179
9812
  } else {
9180
9813
  const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
9181
9814
  const synkroFilePool = sf?.grader?.pool || "auto";
@@ -9189,7 +9822,7 @@ async function installCommand(opts = {}) {
9189
9822
  if (hasCursor) providers.push("cursor");
9190
9823
  }
9191
9824
  ({ claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers));
9192
- if (synkroFilePool !== "auto") console.log(` .synkro: grader pool set to ${poolLabel(synkroFilePool)}`);
9825
+ if (synkroFilePool !== "auto") console.log(` synkro.toml: grader pool set to ${poolLabel(synkroFilePool)}`);
9193
9826
  }
9194
9827
  console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9195
9828
  const connectedRepo = detectGitRepo2() || void 0;
@@ -9200,7 +9833,7 @@ async function installCommand(opts = {}) {
9200
9833
  const ready = await waitForContainerReady(6e4);
9201
9834
  if (ready) {
9202
9835
  console.log(" \u2713 container ready");
9203
- const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9836
+ const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9204
9837
  try {
9205
9838
  const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
9206
9839
  method: "POST",
@@ -9238,7 +9871,7 @@ async function installCommand(opts = {}) {
9238
9871
  try {
9239
9872
  let mcpToken = "";
9240
9873
  try {
9241
- mcpToken = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9874
+ mcpToken = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9242
9875
  } catch {
9243
9876
  }
9244
9877
  if (mcpToken) {
@@ -9319,66 +9952,70 @@ async function installCommand(opts = {}) {
9319
9952
  await setupGithubCommand2({ nonInteractive: true, githubToken: ghToken });
9320
9953
  }
9321
9954
  console.log("\u2713 Synkro installed.");
9955
+ if (process.stdin.isTTY && claudeDesktopInstalled()) {
9956
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
9957
+ const answer = await new Promise((resolve4) => rl.question("\nWould you like Claude Desktop observability? This routes Claude Desktop through a local proxy so its conversations appear in your dashboard (you can stop it anytime with Ctrl+C). [y/N]: ", resolve4));
9958
+ rl.close();
9959
+ if (/^y(es)?$/i.test(answer.trim())) {
9960
+ console.log("\nStarting Claude Desktop observability \u2014 press Ctrl+C to stop.\n");
9961
+ await runClaudeDesktopTap();
9962
+ } else {
9963
+ console.log(" Skipped. You can enable it later by re-running `synkro install`.");
9964
+ }
9965
+ }
9966
+ }
9967
+ function parseTomlValue2(raw) {
9968
+ const v = raw.trim();
9969
+ if (v.startsWith("[") && v.endsWith("]")) {
9970
+ const inner = v.slice(1, -1).trim();
9971
+ if (!inner) return [];
9972
+ return inner.split(",").map((s) => parseTomlValue2(s)).filter((s) => s !== "");
9973
+ }
9974
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
9975
+ if (v === "true") return true;
9976
+ if (v === "false") return false;
9977
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
9978
+ return v;
9322
9979
  }
9323
- function parseSynkroYaml2(raw) {
9980
+ function parseSynkroToml2(raw) {
9324
9981
  const result = {};
9325
- const lines = raw.split("\n");
9326
- let currentKey = "";
9327
- let currentObj = null;
9328
- let currentArr = null;
9329
- for (const line of lines) {
9330
- if (!line.trim() || line.trim().startsWith("#")) continue;
9331
- if (/^\S/.test(line) && line.includes(":")) {
9332
- if (currentObj && currentKey) result[currentKey] = currentObj;
9333
- if (currentArr && currentKey) result[currentKey] = currentArr;
9334
- currentObj = null;
9335
- currentArr = null;
9336
- const colonIdx = line.indexOf(":");
9337
- const key = line.slice(0, colonIdx).trim();
9338
- const val = line.slice(colonIdx + 1).trim();
9339
- currentKey = key;
9340
- if (val) {
9341
- if (val === "[]") result[key] = [];
9342
- else if (val === "true") result[key] = true;
9343
- else if (val === "false") result[key] = false;
9344
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
9345
- else result[key] = val;
9346
- currentKey = "";
9347
- }
9348
- } else if (/^ - /.test(line)) {
9349
- if (!currentArr) currentArr = [];
9350
- currentArr.push(line.replace(/^ - /, "").trim());
9351
- } else if (/^ \S/.test(line) && line.includes(":")) {
9352
- if (!currentObj) currentObj = {};
9353
- const colonIdx = line.indexOf(":");
9354
- const k = line.slice(0, colonIdx).trim();
9355
- const v = line.slice(colonIdx + 1).trim();
9356
- if (v === "true") currentObj[k] = true;
9357
- else if (v === "false") currentObj[k] = false;
9358
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
9359
- else currentObj[k] = v;
9360
- }
9361
- }
9362
- if (currentObj && currentKey) result[currentKey] = currentObj;
9363
- if (currentArr && currentKey) result[currentKey] = currentArr;
9982
+ let section = null;
9983
+ for (const rawLine of raw.split("\n")) {
9984
+ const line = rawLine.trim();
9985
+ if (!line || line.startsWith("#")) continue;
9986
+ const sec = line.match(/^\[([^\]]+)\]$/);
9987
+ if (sec) {
9988
+ section = sec[1].trim();
9989
+ if (!result[section]) result[section] = {};
9990
+ continue;
9991
+ }
9992
+ const eq = line.indexOf("=");
9993
+ if (eq === -1) continue;
9994
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, "");
9995
+ let valRaw = line.slice(eq + 1).trim();
9996
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith("[")) {
9997
+ const h = valRaw.indexOf("#");
9998
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
9999
+ }
10000
+ const value = parseTomlValue2(valRaw);
10001
+ if (section) result[section][key] = value;
10002
+ else result[key] = value;
10003
+ }
9364
10004
  return result;
9365
10005
  }
9366
- function parseSynkroFileRaw(content) {
9367
- return content.trimStart().startsWith("{") ? JSON.parse(content) : parseSynkroYaml2(content);
9368
- }
9369
10006
  function writeSynkroFileIfMissing(opts) {
9370
10007
  try {
9371
10008
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9372
10009
  if (!root) return;
9373
- if (root === homedir8()) return;
9374
- const fp = join8(root, ".synkro");
10010
+ if (root === homedir9()) return;
10011
+ const fp = join9(root, "synkro.toml");
9375
10012
  let hasFile = false;
9376
10013
  try {
9377
10014
  hasFile = statSync2(fp).isFile();
9378
10015
  } catch {
9379
10016
  }
9380
10017
  if (hasFile) {
9381
- console.log(` .synkro: ${fp} (existing, respected)`);
10018
+ console.log(` synkro.toml: ${fp} (existing, respected)`);
9382
10019
  return;
9383
10020
  }
9384
10021
  let pool = "auto";
@@ -9392,27 +10029,26 @@ function writeSynkroFileIfMissing(opts) {
9392
10029
  if (!opts.hasClaudeCode) pool = "cursor";
9393
10030
  }
9394
10031
  const mode = opts.gradingMode === "byok" ? "byok" : "local";
9395
- const yaml = [
9396
- "version: 1",
10032
+ const toml = [
10033
+ "version = 1",
9397
10034
  "",
9398
- "harness:",
9399
- ...harness.map((h) => ` - ${h}`),
10035
+ `harness = [${harness.map((h) => `"${h}"`).join(", ")}]`,
9400
10036
  "",
9401
- "grader:",
9402
- ` pool: ${pool}`,
9403
- ` mode: ${mode}`,
10037
+ 'ruleset = "default"',
9404
10038
  "",
9405
- "ruleset: default",
10039
+ "skills = []",
9406
10040
  "",
9407
- "skills: []",
10041
+ "[grader]",
10042
+ `pool = "${pool}"`,
10043
+ `mode = "${mode}"`,
9408
10044
  "",
9409
- "scanning:",
9410
- " cwe: true",
9411
- " cve: true",
10045
+ "[scanning]",
10046
+ "cwe = true",
10047
+ "cve = true",
9412
10048
  ""
9413
10049
  ].join("\n");
9414
- writeFileSync7(fp, yaml, "utf-8");
9415
- console.log(` .synkro: wrote ${fp} (pool=${pool}, mode=${mode})`);
10050
+ writeFileSync8(fp, toml, "utf-8");
10051
+ console.log(` synkro.toml: wrote ${fp} (pool=${pool}, mode=${mode})`);
9416
10052
  } catch {
9417
10053
  }
9418
10054
  }
@@ -9420,9 +10056,9 @@ function readFullSynkroFile() {
9420
10056
  try {
9421
10057
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9422
10058
  if (!root) return null;
9423
- const fp = join8(root, ".synkro");
9424
- if (!existsSync10(fp)) return null;
9425
- const parsed = parseSynkroFileRaw(readFileSync8(fp, "utf-8"));
10059
+ const fp = join9(root, "synkro.toml");
10060
+ if (!existsSync11(fp)) return null;
10061
+ const parsed = parseSynkroToml2(readFileSync9(fp, "utf-8"));
9426
10062
  const valid = ["claude-code", "cursor"];
9427
10063
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
9428
10064
  const resolved = resolveGraderPool(parsed);
@@ -9455,10 +10091,10 @@ function reconcileHarness() {
9455
10091
  const wantCC = sf.harness.includes("claude-code");
9456
10092
  const wantCursor = sf.harness.includes("cursor");
9457
10093
  for (const w of sf.warnings) console.warn(` \u26A0 ${w}`);
9458
- console.log(`.synkro: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
10094
+ console.log(`synkro.toml: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
9459
10095
  const scripts = writeHookScripts(resolvePersistedHookMode());
9460
10096
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9461
- const ccSettings = join8(homedir8(), ".claude", "settings.json");
10097
+ const ccSettings = join9(homedir9(), ".claude", "settings.json");
9462
10098
  if (wantCC) {
9463
10099
  installCCHooks(ccSettings, {
9464
10100
  bashJudgeScriptPath: scripts.bashScript,
@@ -9472,11 +10108,12 @@ function reconcileHarness() {
9472
10108
  sessionStartScriptPath: scripts.sessionStartScript,
9473
10109
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
9474
10110
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9475
- installScanScriptPath: scripts.installScanScript
10111
+ installScanScriptPath: scripts.installScanScript,
10112
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript
9476
10113
  });
9477
10114
  console.log(" \u2713 Claude Code hooks registered");
9478
10115
  try {
9479
- const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
10116
+ const mcpJwt = readFileSync9(join9(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
9480
10117
  if (mcpJwt) {
9481
10118
  installMcpConfig({ gatewayUrl: "", bearerToken: mcpJwt, local: true });
9482
10119
  console.log(" \u2713 Claude Code MCP registered");
@@ -9487,7 +10124,7 @@ function reconcileHarness() {
9487
10124
  if (uninstallCCHooks(ccSettings)) console.log(" \u2717 Claude Code hooks removed");
9488
10125
  if (uninstallMcpConfig()) console.log(" \u2717 Claude Code MCP removed");
9489
10126
  }
9490
- const cursorHooks = join8(homedir8(), ".cursor", "hooks.json");
10127
+ const cursorHooks = join9(homedir9(), ".cursor", "hooks.json");
9491
10128
  if (wantCursor) {
9492
10129
  installCursorHooks(cursorHooks, {
9493
10130
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
@@ -9540,29 +10177,31 @@ async function syncSkillFiles() {
9540
10177
  if (resolved.length === 0) return;
9541
10178
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
9542
10179
  const tasks = resolved.map((fp) => {
9543
- const content = readFileSync8(fp, "utf-8");
10180
+ const content = readFileSync9(fp, "utf-8");
9544
10181
  const source = `skill:${fp.split("/").pop()}`;
9545
- if (!content.trim()) return null;
10182
+ if (!content.trim()) {
10183
+ console.log(` \u2298 skill ${source}: empty file, skipped`);
10184
+ return null;
10185
+ }
10186
+ const lines = content.split("\n").length;
10187
+ console.log(` \u2192 read ${source} (${lines} lines, ${(content.length / 1024).toFixed(1)} KB)`);
9546
10188
  return { source, content };
9547
10189
  }).filter(Boolean);
9548
10190
  if (tasks.length === 0) return;
9549
- const results = await Promise.allSettled(tasks.map(async ({ source, content }) => {
9550
- const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/skills/sync`, {
9551
- method: "POST",
9552
- headers: { "Content-Type": "application/json" },
9553
- body: JSON.stringify({ source, content }),
9554
- signal: AbortSignal.timeout(65e3)
9555
- });
9556
- if (!resp.ok) throw new Error(`${resp.status}`);
9557
- const result = await resp.json();
9558
- return { source, ...result };
9559
- }));
9560
- for (const r of results) {
9561
- if (r.status === "fulfilled") {
9562
- const { source, created, message } = r.value;
9563
- console.log(created > 0 ? ` \u2713 skill ${source}: ${created} new rules added` : ` \u2713 skill ${source}: ${message || "up to date"}`);
9564
- } else {
9565
- console.warn(` \u26A0 skill sync failed: ${r.reason}`);
10191
+ console.log(` indexing ${tasks.length} skill file${tasks.length === 1 ? "" : "s"} \u2014 comparing against existing ruleset (this may take a few seconds)...`);
10192
+ for (const { source, content } of tasks) {
10193
+ try {
10194
+ const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/skills/sync`, {
10195
+ method: "POST",
10196
+ headers: { "Content-Type": "application/json" },
10197
+ body: JSON.stringify({ source, content }),
10198
+ signal: AbortSignal.timeout(65e3)
10199
+ });
10200
+ if (!resp.ok) throw new Error(`${resp.status}`);
10201
+ const { created, message } = await resp.json();
10202
+ console.log(created > 0 ? ` \u2713 skill ${source}: ${created} new rule${created === 1 ? "" : "s"} added to index` : ` \u2713 skill ${source}: ${message || "up to date"} \u2014 index unchanged`);
10203
+ } catch (e) {
10204
+ console.warn(` \u26A0 skill ${source}: sync failed (${e instanceof Error ? e.message : String(e)})`);
9566
10205
  }
9567
10206
  }
9568
10207
  }
@@ -9584,17 +10223,17 @@ function detectGitRepo2() {
9584
10223
  function getClaudeProjectsFolder() {
9585
10224
  const cwd = process.cwd();
9586
10225
  const sanitized = "-" + cwd.replace(/\//g, "-");
9587
- const projectsDir = join8(homedir8(), ".claude", "projects", sanitized);
9588
- return existsSync10(projectsDir) ? projectsDir : null;
10226
+ const projectsDir = join9(homedir9(), ".claude", "projects", sanitized);
10227
+ return existsSync11(projectsDir) ? projectsDir : null;
9589
10228
  }
9590
10229
  function extractSessionInsights(projectsDir) {
9591
10230
  const insights = [];
9592
10231
  const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
9593
10232
  for (const file of files) {
9594
10233
  const sessionId = file.replace(".jsonl", "");
9595
- const filePath = join8(projectsDir, file);
10234
+ const filePath = join9(projectsDir, file);
9596
10235
  try {
9597
- const content = readFileSync8(filePath, "utf-8");
10236
+ const content = readFileSync9(filePath, "utf-8");
9598
10237
  const lines = content.split("\n").filter(Boolean);
9599
10238
  for (let i = 0; i < lines.length; i++) {
9600
10239
  try {
@@ -9673,14 +10312,14 @@ function cursorProjectSlug(workspaceRoot) {
9673
10312
  return workspaceRoot.replace(/^[/]+/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9674
10313
  }
9675
10314
  function getCursorTranscriptsDir() {
9676
- const dir = join8(homedir8(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
9677
- return existsSync10(dir) ? dir : null;
10315
+ const dir = join9(homedir9(), ".cursor", "projects", cursorProjectSlug(process.cwd()), "agent-transcripts");
10316
+ return existsSync11(dir) ? dir : null;
9678
10317
  }
9679
10318
  function isSafeConvId(id) {
9680
10319
  return /^[A-Za-z0-9_-]+$/.test(id);
9681
10320
  }
9682
10321
  function parseCursorTranscriptFile(filePath) {
9683
- const content = readFileSync8(filePath, "utf-8");
10322
+ const content = readFileSync9(filePath, "utf-8");
9684
10323
  const lines = content.split("\n").filter(Boolean);
9685
10324
  const messages = [];
9686
10325
  for (let i = 0; i < lines.length; i++) {
@@ -9712,8 +10351,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9712
10351
  for (let i = 0; i < convDirs.length; i++) {
9713
10352
  const convId = convDirs[i];
9714
10353
  if (!isSafeConvId(convId)) continue;
9715
- const filePath = join8(dir, convId, `${convId}.jsonl`);
9716
- if (!existsSync10(filePath)) continue;
10354
+ const filePath = join9(dir, convId, `${convId}.jsonl`);
10355
+ if (!existsSync11(filePath)) continue;
9717
10356
  try {
9718
10357
  const all = parseCursorTranscriptFile(filePath);
9719
10358
  const messages = all.length > 500 ? all.slice(-500) : all;
@@ -9735,8 +10374,8 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9735
10374
  process.stdout.write(`\r Progress: ${i + 1}/${convDirs.length} sessions (${totalMessages} messages embedded) `);
9736
10375
  }
9737
10376
  try {
9738
- const lc = readFileSync8(filePath, "utf-8").split("\n").filter(Boolean).length;
9739
- writeFileSync7(join8(OFFSETS_DIR, convId), String(lc), "utf-8");
10377
+ const lc = readFileSync9(filePath, "utf-8").split("\n").filter(Boolean).length;
10378
+ writeFileSync8(join9(OFFSETS_DIR, convId), String(lc), "utf-8");
9740
10379
  } catch {
9741
10380
  }
9742
10381
  }
@@ -9744,7 +10383,7 @@ async function syncCursorTranscriptsLocal(mcpPort, mcpToken, repo) {
9744
10383
  return { sessions: totalSessions, messages: totalMessages };
9745
10384
  }
9746
10385
  function parseTranscriptFile(filePath) {
9747
- const content = readFileSync8(filePath, "utf-8");
10386
+ const content = readFileSync9(filePath, "utf-8");
9748
10387
  const lines = content.split("\n").filter(Boolean);
9749
10388
  const messages = [];
9750
10389
  for (let i = 0; i < lines.length; i++) {
@@ -9792,7 +10431,7 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9792
10431
  for (let i = 0; i < files.length; i++) {
9793
10432
  const file = files[i];
9794
10433
  const sessionId = file.replace(".jsonl", "");
9795
- const filePath = join8(projectsDir, file);
10434
+ const filePath = join9(projectsDir, file);
9796
10435
  try {
9797
10436
  const allMessages = parseTranscriptFile(filePath);
9798
10437
  const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
@@ -9814,9 +10453,9 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
9814
10453
  process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
9815
10454
  }
9816
10455
  try {
9817
- const content = readFileSync8(join8(projectsDir, file), "utf-8");
10456
+ const content = readFileSync9(join9(projectsDir, file), "utf-8");
9818
10457
  const lineCount = content.split("\n").filter(Boolean).length;
9819
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10458
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9820
10459
  } catch {
9821
10460
  }
9822
10461
  }
@@ -9837,7 +10476,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9837
10476
  const sessions = [];
9838
10477
  for (const file of batch) {
9839
10478
  const sessionId = file.replace(".jsonl", "");
9840
- const filePath = join8(projectsDir, file);
10479
+ const filePath = join9(projectsDir, file);
9841
10480
  try {
9842
10481
  const allMessages = parseTranscriptFile(filePath);
9843
10482
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -9866,11 +10505,11 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
9866
10505
  }
9867
10506
  for (const file of batch) {
9868
10507
  const sessionId = file.replace(".jsonl", "");
9869
- const filePath = join8(projectsDir, file);
10508
+ const filePath = join9(projectsDir, file);
9870
10509
  try {
9871
- const content = readFileSync8(filePath, "utf-8");
10510
+ const content = readFileSync9(filePath, "utf-8");
9872
10511
  const lineCount = content.split("\n").filter(Boolean).length;
9873
- writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
10512
+ writeFileSync8(join9(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
9874
10513
  } catch {
9875
10514
  }
9876
10515
  }
@@ -9893,11 +10532,12 @@ var init_install = __esm({
9893
10532
  init_repoConnect();
9894
10533
  init_projects();
9895
10534
  init_promptFetcher();
10535
+ init_claudeDesktopTap();
9896
10536
  init_dockerInstall();
9897
- SYNKRO_DIR4 = join8(homedir8(), ".synkro");
9898
- HOOKS_DIR = join8(SYNKRO_DIR4, "hooks");
9899
- BIN_DIR = join8(SYNKRO_DIR4, "bin");
9900
- CONFIG_PATH2 = join8(SYNKRO_DIR4, "config.env");
10537
+ SYNKRO_DIR4 = join9(homedir9(), ".synkro");
10538
+ HOOKS_DIR = join9(SYNKRO_DIR4, "hooks");
10539
+ BIN_DIR = join9(SYNKRO_DIR4, "bin");
10540
+ CONFIG_PATH2 = join9(SYNKRO_DIR4, "config.env");
9901
10541
  MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
9902
10542
  import { readFileSync } from 'node:fs';
9903
10543
  import { homedir } from 'node:os';
@@ -9909,56 +10549,120 @@ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
9909
10549
  const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '18931', 10);
9910
10550
  const URL = \`http://127.0.0.1:\${PORT}\`;
9911
10551
 
9912
- let token = '';
9913
- try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
10552
+ // Re-read the JWT on EVERY request, not once at spawn: a \`synkro restart\` rotates
10553
+ // the token, and a proxy that cached the old one would 401 every call until it
10554
+ // was respawned (a manual /mcp).
10555
+ function loadToken() {
10556
+ try { return readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch { return ''; }
10557
+ }
10558
+ function write(obj) { try { process.stdout.write(JSON.stringify(obj) + '\\n'); } catch {} }
10559
+
10560
+ // Answer \`initialize\` LOCALLY without touching the backend. Claude Code latches an
10561
+ // MCP server as failed \u2014 forcing a manual /mcp \u2014 only when the child process exits
10562
+ // or the initialize handshake fails. A \`synkro restart\` takes the backend down for
10563
+ // ~30-60s, so if CC (re)connects in that window a forwarded initialize would fail
10564
+ // and the server would be dead until /mcp. Replying here makes the handshake
10565
+ // succeed unconditionally; tools/list and tools/call still hit the backend.
10566
+ function localInitialize(msg) {
10567
+ return {
10568
+ jsonrpc: '2.0',
10569
+ id: msg.id,
10570
+ result: {
10571
+ protocolVersion: (msg && msg.params && msg.params.protocolVersion) || '2024-11-05',
10572
+ capabilities: { tools: {} },
10573
+ serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },
10574
+ },
10575
+ };
10576
+ }
10577
+
10578
+ // Cap a single JSON-RPC line. This proxy only carries small tool-call envelopes;
10579
+ // refusing oversized input keeps a malicious or runaway client from buffering
10580
+ // unbounded memory (CWE-400).
10581
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
9914
10582
 
9915
10583
  const rl = createInterface({ input: process.stdin, terminal: false });
9916
10584
 
9917
10585
  rl.on('line', async (line) => {
9918
10586
  if (!line.trim()) return;
10587
+ if (line.length > MAX_LINE_BYTES) return;
9919
10588
  let msg;
9920
10589
  try { msg = JSON.parse(line); } catch { return; }
9921
- if (!msg.id && msg.method?.startsWith('notifications/')) return;
10590
+ // Notifications carry no id and need no reply.
10591
+ if (msg.id == null && typeof msg.method === 'string' && msg.method.startsWith('notifications/')) return;
9922
10592
 
9923
- try {
9924
- const resp = await fetch(URL, {
9925
- method: 'POST',
9926
- headers: {
9927
- 'Content-Type': 'application/json',
9928
- 'Authorization': \`Bearer \${token}\`,
9929
- },
9930
- body: line,
9931
- signal: AbortSignal.timeout(30000),
9932
- });
9933
- if (resp.status === 204) return;
9934
- const body = await resp.text();
9935
- process.stdout.write(body + '\\n');
9936
- } catch (err) {
9937
- if (msg.id != null) {
9938
- process.stdout.write(JSON.stringify({
9939
- jsonrpc: '2.0',
9940
- id: msg.id,
9941
- error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
9942
- }) + '\\n');
10593
+ // initialize: try the backend briefly for the real instructions, but ALWAYS
10594
+ // fall back to a local reply so the handshake can never block or fail.
10595
+ if (msg.method === 'initialize') {
10596
+ try {
10597
+ const resp = await fetch(URL, {
10598
+ method: 'POST',
10599
+ headers: { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${loadToken()}\` },
10600
+ body: line,
10601
+ signal: AbortSignal.timeout(2500),
10602
+ });
10603
+ if (resp.ok) { process.stdout.write((await resp.text()) + '\\n'); return; }
10604
+ } catch {}
10605
+ write(localInitialize(msg));
10606
+ return;
10607
+ }
10608
+
10609
+ // tool calls: forward, allowing a generous per-attempt window for slow calls
10610
+ // (create + decompose). If the backend is UNREACHABLE (mid-restart), retry only
10611
+ // briefly, then return a CLEAN JSON-RPC error and move on. Never hold the request
10612
+ // open for tens of seconds \u2014 a stdio request held that long wedges CC's MCP
10613
+ // client and every later call queues behind it. A returned error keeps the
10614
+ // client responsive and the server connected; the agent just retries.
10615
+ const REFUSED_BUDGET_MS = 4000;
10616
+ const deadline = Date.now() + REFUSED_BUDGET_MS;
10617
+ let attempt = 0;
10618
+ while (true) {
10619
+ try {
10620
+ const resp = await fetch(URL, {
10621
+ method: 'POST',
10622
+ headers: { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${loadToken()}\` },
10623
+ body: line,
10624
+ signal: AbortSignal.timeout(30000),
10625
+ });
10626
+ if (resp.status === 204) return;
10627
+ process.stdout.write((await resp.text()) + '\\n');
10628
+ return;
10629
+ } catch (err) {
10630
+ // A 30s timeout means the backend was UP but slow \u2014 don't spin. ECONNREFUSED /
10631
+ // fetch failed means it's unreachable \u2192 retry briefly within the budget.
10632
+ const name = err && err.name;
10633
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
10634
+ if (!isTimeout && Date.now() < deadline) {
10635
+ attempt++;
10636
+ await new Promise((r) => setTimeout(r, Math.min(500, 150 * attempt)));
10637
+ continue;
10638
+ }
10639
+ if (msg.id != null) {
10640
+ write({
10641
+ jsonrpc: '2.0',
10642
+ id: msg.id,
10643
+ error: { code: -32603, message: 'Synkro MCP backend unavailable (container may be restarting) \u2014 retry in a moment' },
10644
+ });
10645
+ }
10646
+ return;
9943
10647
  }
9944
10648
  }
9945
10649
  });
9946
10650
  `;
9947
- OFFSETS_DIR = join8(SYNKRO_DIR4, ".transcript-offsets");
10651
+ OFFSETS_DIR = join9(SYNKRO_DIR4, ".transcript-offsets");
9948
10652
  }
9949
10653
  });
9950
10654
 
9951
10655
  // cli/local-cc/install.ts
9952
- import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
9953
- import { join as join9 } from "path";
9954
- import { homedir as homedir9 } from "os";
10656
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10657
+ import { join as join10 } from "path";
10658
+ import { homedir as homedir10 } from "os";
9955
10659
  import { spawnSync as spawnSync4 } from "child_process";
9956
10660
  function writePluginFiles() {
9957
10661
  for (const c of CHANNELS) {
9958
10662
  mkdirSync9(c.sessionDir, { recursive: true });
9959
10663
  mkdirSync9(c.pluginSettingsDir, { recursive: true });
9960
- writeFileSync8(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
9961
- writeFileSync8(
10664
+ writeFileSync9(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10665
+ writeFileSync9(
9962
10666
  c.pluginSettingsPath,
9963
10667
  JSON.stringify({
9964
10668
  fastMode: true,
@@ -9966,7 +10670,7 @@ function writePluginFiles() {
9966
10670
  }, null, 2) + "\n",
9967
10671
  "utf-8"
9968
10672
  );
9969
- writeFileSync8(c.runScriptPath, c.runScriptSource, "utf-8");
10673
+ writeFileSync9(c.runScriptPath, c.runScriptSource, "utf-8");
9970
10674
  chmodSync3(c.runScriptPath, 493);
9971
10675
  }
9972
10676
  }
@@ -9985,10 +10689,10 @@ function runBunInstall() {
9985
10689
  }
9986
10690
  }
9987
10691
  function safelyMutateClaudeJson(mutator) {
9988
- if (!existsSync11(CLAUDE_JSON_PATH)) {
10692
+ if (!existsSync12(CLAUDE_JSON_PATH)) {
9989
10693
  return;
9990
10694
  }
9991
- const originalText = readFileSync9(CLAUDE_JSON_PATH, "utf-8");
10695
+ const originalText = readFileSync10(CLAUDE_JSON_PATH, "utf-8");
9992
10696
  let parsed;
9993
10697
  try {
9994
10698
  parsed = JSON.parse(originalText);
@@ -10020,7 +10724,7 @@ function safelyMutateClaudeJson(mutator) {
10020
10724
  copyFileSync2(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
10021
10725
  const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
10022
10726
  try {
10023
- writeFileSync8(tmpPath, newText, "utf-8");
10727
+ writeFileSync9(tmpPath, newText, "utf-8");
10024
10728
  const fd = openSync(tmpPath, "r");
10025
10729
  try {
10026
10730
  fsyncSync(fd);
@@ -10053,7 +10757,7 @@ function writeProjectMcpJson() {
10053
10757
  }
10054
10758
  }
10055
10759
  };
10056
- writeFileSync8(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10760
+ writeFileSync9(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
10057
10761
  }
10058
10762
  }
10059
10763
  function patchClaudeJson() {
@@ -10130,42 +10834,42 @@ var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_S
10130
10834
  var init_install2 = __esm({
10131
10835
  "cli/local-cc/install.ts"() {
10132
10836
  "use strict";
10133
- CLAUDE_JSON_BACKUP_PATH = join9(homedir9(), ".claude.json.synkro-bak");
10134
- SESSION_DIR = join9(homedir9(), ".synkro", "cc_sessions");
10135
- PLUGIN_PATH = join9(SESSION_DIR, "synkro-channel.ts");
10136
- PLUGIN_PKG_PATH = join9(SESSION_DIR, "package.json");
10137
- PLUGIN_SETTINGS_DIR = join9(SESSION_DIR, ".claude");
10138
- PLUGIN_SETTINGS_PATH = join9(PLUGIN_SETTINGS_DIR, "settings.json");
10139
- PROJECT_MCP_PATH = join9(SESSION_DIR, ".mcp.json");
10140
- CLAUDE_JSON_PATH = join9(homedir9(), ".claude.json");
10141
- RUN_SCRIPT_PATH = join9(SESSION_DIR, "run-claude.sh");
10837
+ CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
10838
+ SESSION_DIR = join10(homedir10(), ".synkro", "cc_sessions");
10839
+ PLUGIN_PATH = join10(SESSION_DIR, "synkro-channel.ts");
10840
+ PLUGIN_PKG_PATH = join10(SESSION_DIR, "package.json");
10841
+ PLUGIN_SETTINGS_DIR = join10(SESSION_DIR, ".claude");
10842
+ PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
10843
+ PROJECT_MCP_PATH = join10(SESSION_DIR, ".mcp.json");
10844
+ CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
10845
+ RUN_SCRIPT_PATH = join10(SESSION_DIR, "run-claude.sh");
10142
10846
  TMUX_SESSION_NAME = "synkro-local-cc";
10143
10847
  CHANNEL_1_PORT = 8941;
10144
- SESSION_DIR_2 = join9(homedir9(), ".synkro", "cc_sessions_2");
10145
- PLUGIN_PATH_2 = join9(SESSION_DIR_2, "synkro-channel.ts");
10146
- PLUGIN_PKG_PATH_2 = join9(SESSION_DIR_2, "package.json");
10147
- PLUGIN_SETTINGS_DIR_2 = join9(SESSION_DIR_2, ".claude");
10148
- PLUGIN_SETTINGS_PATH_2 = join9(PLUGIN_SETTINGS_DIR_2, "settings.json");
10149
- PROJECT_MCP_PATH_2 = join9(SESSION_DIR_2, ".mcp.json");
10150
- RUN_SCRIPT_PATH_2 = join9(SESSION_DIR_2, "run-claude.sh");
10848
+ SESSION_DIR_2 = join10(homedir10(), ".synkro", "cc_sessions_2");
10849
+ PLUGIN_PATH_2 = join10(SESSION_DIR_2, "synkro-channel.ts");
10850
+ PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_2, "package.json");
10851
+ PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_2, ".claude");
10852
+ PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
10853
+ PROJECT_MCP_PATH_2 = join10(SESSION_DIR_2, ".mcp.json");
10854
+ RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_2, "run-claude.sh");
10151
10855
  TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
10152
10856
  CHANNEL_2_PORT = 8951;
10153
- SESSION_DIR_3 = join9(homedir9(), ".synkro", "cc_sessions_3");
10154
- PLUGIN_PATH_3 = join9(SESSION_DIR_3, "synkro-channel.ts");
10155
- PLUGIN_PKG_PATH_3 = join9(SESSION_DIR_3, "package.json");
10156
- PLUGIN_SETTINGS_DIR_3 = join9(SESSION_DIR_3, ".claude");
10157
- PLUGIN_SETTINGS_PATH_3 = join9(PLUGIN_SETTINGS_DIR_3, "settings.json");
10158
- PROJECT_MCP_PATH_3 = join9(SESSION_DIR_3, ".mcp.json");
10159
- RUN_SCRIPT_PATH_3 = join9(SESSION_DIR_3, "run-claude.sh");
10857
+ SESSION_DIR_3 = join10(homedir10(), ".synkro", "cc_sessions_3");
10858
+ PLUGIN_PATH_3 = join10(SESSION_DIR_3, "synkro-channel.ts");
10859
+ PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_3, "package.json");
10860
+ PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_3, ".claude");
10861
+ PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
10862
+ PROJECT_MCP_PATH_3 = join10(SESSION_DIR_3, ".mcp.json");
10863
+ RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_3, "run-claude.sh");
10160
10864
  TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
10161
10865
  CHANNEL_3_PORT = 8942;
10162
- SESSION_DIR_4 = join9(homedir9(), ".synkro", "cc_sessions_4");
10163
- PLUGIN_PATH_4 = join9(SESSION_DIR_4, "synkro-channel.ts");
10164
- PLUGIN_PKG_PATH_4 = join9(SESSION_DIR_4, "package.json");
10165
- PLUGIN_SETTINGS_DIR_4 = join9(SESSION_DIR_4, ".claude");
10166
- PLUGIN_SETTINGS_PATH_4 = join9(PLUGIN_SETTINGS_DIR_4, "settings.json");
10167
- PROJECT_MCP_PATH_4 = join9(SESSION_DIR_4, ".mcp.json");
10168
- RUN_SCRIPT_PATH_4 = join9(SESSION_DIR_4, "run-claude.sh");
10866
+ SESSION_DIR_4 = join10(homedir10(), ".synkro", "cc_sessions_4");
10867
+ PLUGIN_PATH_4 = join10(SESSION_DIR_4, "synkro-channel.ts");
10868
+ PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_4, "package.json");
10869
+ PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_4, ".claude");
10870
+ PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
10871
+ PROJECT_MCP_PATH_4 = join10(SESSION_DIR_4, ".mcp.json");
10872
+ RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_4, "run-claude.sh");
10169
10873
  TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
10170
10874
  CHANNEL_4_PORT = 8952;
10171
10875
  RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
@@ -10439,9 +11143,9 @@ var disconnect_exports = {};
10439
11143
  __export(disconnect_exports, {
10440
11144
  disconnectCommand: () => disconnectCommand
10441
11145
  });
10442
- import { existsSync as existsSync12, rmSync, readdirSync as readdirSync4 } from "fs";
10443
- import { homedir as homedir10 } from "os";
10444
- import { join as join10 } from "path";
11146
+ import { existsSync as existsSync13, rmSync, readdirSync as readdirSync4 } from "fs";
11147
+ import { homedir as homedir11 } from "os";
11148
+ import { join as join11 } from "path";
10445
11149
  import { spawnSync as spawnSync5 } from "child_process";
10446
11150
  import { createInterface as createInterface4 } from "readline";
10447
11151
  async function tearDownLocalCC() {
@@ -10515,15 +11219,15 @@ async function disconnectCommand(args2 = []) {
10515
11219
  const cursorMcpRemoved = uninstallCursorMcpConfig();
10516
11220
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
10517
11221
  }
10518
- if (existsSync12(SYNKRO_DIR5)) {
11222
+ if (existsSync13(SYNKRO_DIR5)) {
10519
11223
  if (purge) {
10520
11224
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
10521
11225
  console.log(`\u2713 wiped ${SYNKRO_DIR5} entirely \u2014 including all scan data and backups`);
10522
11226
  } else {
10523
- const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
11227
+ const keep = /* @__PURE__ */ new Set([join11(SYNKRO_DIR5, "pgdata"), join11(SYNKRO_DIR5, "pgdata-backups")]);
10524
11228
  const preserved = [];
10525
11229
  for (const entry of readdirSync4(SYNKRO_DIR5)) {
10526
- const full = join10(SYNKRO_DIR5, entry);
11230
+ const full = join11(SYNKRO_DIR5, entry);
10527
11231
  if (keep.has(full)) {
10528
11232
  preserved.push(entry);
10529
11233
  continue;
@@ -10553,14 +11257,14 @@ var init_disconnect = __esm({
10553
11257
  init_install2();
10554
11258
  init_dockerInstall();
10555
11259
  init_macKeychain();
10556
- SYNKRO_DIR5 = join10(homedir10(), ".synkro");
11260
+ SYNKRO_DIR5 = join11(homedir11(), ".synkro");
10557
11261
  }
10558
11262
  });
10559
11263
 
10560
11264
  // cli/local-cc/turnLog.ts
10561
- import { appendFileSync, existsSync as existsSync13, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync10, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
10562
- import { dirname as dirname6, join as join11 } from "path";
10563
- import { homedir as homedir11 } from "os";
11265
+ import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
11266
+ import { dirname as dirname6, join as join12 } from "path";
11267
+ import { homedir as homedir12 } from "os";
10564
11268
  function truncate(s, max = PREVIEW_MAX) {
10565
11269
  if (s.length <= max) return s;
10566
11270
  return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
@@ -10596,11 +11300,11 @@ function appendTurn(args2) {
10596
11300
  }
10597
11301
  }
10598
11302
  function readRecentTurns(n = 20) {
10599
- if (!existsSync13(TURN_LOG_PATH)) return [];
11303
+ if (!existsSync14(TURN_LOG_PATH)) return [];
10600
11304
  try {
10601
11305
  const size = statSync3(TURN_LOG_PATH).size;
10602
11306
  if (size === 0) return [];
10603
- const text = readFileSync10(TURN_LOG_PATH, "utf-8");
11307
+ const text = readFileSync11(TURN_LOG_PATH, "utf-8");
10604
11308
  const lines = text.split("\n").filter(Boolean);
10605
11309
  const lastN = lines.slice(-n).reverse();
10606
11310
  return lastN.map((line) => {
@@ -10617,7 +11321,7 @@ function readRecentTurns(n = 20) {
10617
11321
  function followTurns(onEntry) {
10618
11322
  try {
10619
11323
  mkdirSync10(dirname6(TURN_LOG_PATH), { recursive: true });
10620
- if (!existsSync13(TURN_LOG_PATH)) {
11324
+ if (!existsSync14(TURN_LOG_PATH)) {
10621
11325
  appendFileSync(TURN_LOG_PATH, "", "utf-8");
10622
11326
  }
10623
11327
  } catch {
@@ -10679,7 +11383,7 @@ var TURN_LOG_PATH, PREVIEW_MAX;
10679
11383
  var init_turnLog = __esm({
10680
11384
  "cli/local-cc/turnLog.ts"() {
10681
11385
  "use strict";
10682
- TURN_LOG_PATH = join11(homedir11(), ".synkro", "cc_sessions", "turns.log");
11386
+ TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
10683
11387
  PREVIEW_MAX = 400;
10684
11388
  }
10685
11389
  });
@@ -10828,9 +11532,9 @@ var init_grade = __esm({
10828
11532
  });
10829
11533
 
10830
11534
  // cli/local-cc/pueue.ts
10831
- import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn } from "child_process";
10832
- import { homedir as homedir12 } from "os";
10833
- import { join as join12 } from "path";
11535
+ import { execFileSync as execFileSync3, spawnSync as spawnSync6, spawn as spawn2 } from "child_process";
11536
+ import { homedir as homedir13 } from "os";
11537
+ import { join as join13 } from "path";
10834
11538
  import { connect as connect2 } from "net";
10835
11539
  function pueueAvailable() {
10836
11540
  const r = spawnSync6("pueue", ["--version"], { encoding: "utf-8" });
@@ -10896,7 +11600,7 @@ function startTask(opts = {}) {
10896
11600
  spawnSync6("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
10897
11601
  existing = findTask(ch);
10898
11602
  }
10899
- const runScript = join12(cwd, "run-claude.sh");
11603
+ const runScript = join13(cwd, "run-claude.sh");
10900
11604
  const args2 = [
10901
11605
  "add",
10902
11606
  "--label",
@@ -10993,7 +11697,7 @@ function assertPueueInstalled() {
10993
11697
  const status = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
10994
11698
  if (status.status !== 0) {
10995
11699
  console.log(" Starting pueued daemon...");
10996
- const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
11700
+ const child = spawn2("pueued", ["-d"], { stdio: "ignore", detached: true });
10997
11701
  child.unref();
10998
11702
  spawnSync6("sleep", ["1"]);
10999
11703
  const retry = spawnSync6("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
@@ -11026,12 +11730,12 @@ var init_pueue = __esm({
11026
11730
  "use strict";
11027
11731
  TASK_LABEL = "synkro-local-cc";
11028
11732
  TMUX_SESSION = "synkro-local-cc";
11029
- SESSION_DIR2 = join12(homedir12(), ".synkro", "cc_sessions");
11733
+ SESSION_DIR2 = join13(homedir13(), ".synkro", "cc_sessions");
11030
11734
  TASK_LABEL_2 = "synkro-local-cc-2";
11031
11735
  TMUX_SESSION_2 = "synkro-local-cc-2";
11032
- SESSION_DIR_22 = join12(homedir12(), ".synkro", "cc_sessions_2");
11033
- SESSION_DIR_32 = join12(homedir12(), ".synkro", "cc_sessions_3");
11034
- SESSION_DIR_42 = join12(homedir12(), ".synkro", "cc_sessions_4");
11736
+ SESSION_DIR_22 = join13(homedir13(), ".synkro", "cc_sessions_2");
11737
+ SESSION_DIR_32 = join13(homedir13(), ".synkro", "cc_sessions_3");
11738
+ SESSION_DIR_42 = join13(homedir13(), ".synkro", "cc_sessions_4");
11035
11739
  PueueError = class extends Error {
11036
11740
  constructor(message, cause) {
11037
11741
  super(message);
@@ -11046,13 +11750,13 @@ var init_pueue = __esm({
11046
11750
  });
11047
11751
 
11048
11752
  // cli/local-cc/settings.ts
11049
- import { existsSync as existsSync14, readFileSync as readFileSync11 } from "fs";
11050
- import { homedir as homedir13 } from "os";
11051
- import { join as join13 } from "path";
11753
+ import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
11754
+ import { homedir as homedir14 } from "os";
11755
+ import { join as join14 } from "path";
11052
11756
  function isLocalCCEnabled() {
11053
- if (!existsSync14(CONFIG_PATH3)) return false;
11757
+ if (!existsSync15(CONFIG_PATH3)) return false;
11054
11758
  try {
11055
- const content = readFileSync11(CONFIG_PATH3, "utf-8");
11759
+ const content = readFileSync12(CONFIG_PATH3, "utf-8");
11056
11760
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
11057
11761
  return match?.[1] === "yes";
11058
11762
  } catch {
@@ -11063,7 +11767,7 @@ var CONFIG_PATH3;
11063
11767
  var init_settings = __esm({
11064
11768
  "cli/local-cc/settings.ts"() {
11065
11769
  "use strict";
11066
- CONFIG_PATH3 = join13(homedir13(), ".synkro", "config.env");
11770
+ CONFIG_PATH3 = join14(homedir14(), ".synkro", "config.env");
11067
11771
  }
11068
11772
  });
11069
11773
 
@@ -11073,10 +11777,10 @@ __export(localCc_exports, {
11073
11777
  localCcCommand: () => localCcCommand
11074
11778
  });
11075
11779
  import { spawnSync as spawnSync7 } from "child_process";
11076
- import { homedir as homedir14 } from "os";
11077
- import { join as join14 } from "path";
11780
+ import { homedir as homedir15 } from "os";
11781
+ import { join as join15 } from "path";
11078
11782
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
11079
- import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
11783
+ import { existsSync as existsSync16, readFileSync as readFileSync13, writeFileSync as writeFileSync10 } from "fs";
11080
11784
  function deploymentMode() {
11081
11785
  const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
11082
11786
  if (env === "docker") return "docker";
@@ -11182,15 +11886,15 @@ TROUBLESHOOTING
11182
11886
  `);
11183
11887
  }
11184
11888
  function readGatewayUrl() {
11185
- if (existsSync15(CONFIG_PATH4)) {
11186
- const m = readFileSync12(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11889
+ if (existsSync16(CONFIG_PATH4)) {
11890
+ const m = readFileSync13(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
11187
11891
  if (m) return m[1];
11188
11892
  }
11189
11893
  return "https://api.synkro.sh";
11190
11894
  }
11191
11895
  function updateLocalInferenceFlag(enabled) {
11192
- if (!existsSync15(CONFIG_PATH4)) return;
11193
- let content = readFileSync12(CONFIG_PATH4, "utf-8");
11896
+ if (!existsSync16(CONFIG_PATH4)) return;
11897
+ let content = readFileSync13(CONFIG_PATH4, "utf-8");
11194
11898
  const flag = enabled ? "yes" : "no";
11195
11899
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
11196
11900
  content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
@@ -11199,7 +11903,7 @@ function updateLocalInferenceFlag(enabled) {
11199
11903
  SYNKRO_LOCAL_INFERENCE='${flag}'
11200
11904
  `;
11201
11905
  }
11202
- writeFileSync9(CONFIG_PATH4, content, "utf-8");
11906
+ writeFileSync10(CONFIG_PATH4, content, "utf-8");
11203
11907
  }
11204
11908
  async function setServerGradingProvider(provider) {
11205
11909
  await ensureValidToken();
@@ -11629,8 +12333,8 @@ var init_localCc = __esm({
11629
12333
  init_install();
11630
12334
  init_client();
11631
12335
  init_stub();
11632
- SYNKRO_CONFIG_PATH = join14(homedir14(), ".synkro", "config.env");
11633
- CONFIG_PATH4 = join14(homedir14(), ".synkro", "config.env");
12336
+ SYNKRO_CONFIG_PATH = join15(homedir15(), ".synkro", "config.env");
12337
+ CONFIG_PATH4 = join15(homedir15(), ".synkro", "config.env");
11634
12338
  }
11635
12339
  });
11636
12340
 
@@ -11741,13 +12445,13 @@ var config_exports = {};
11741
12445
  __export(config_exports, {
11742
12446
  configCommand: () => configCommand
11743
12447
  });
11744
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync16 } from "fs";
11745
- import { join as join15 } from "path";
11746
- import { homedir as homedir15 } from "os";
12448
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync11, existsSync as existsSync17 } from "fs";
12449
+ import { join as join16 } from "path";
12450
+ import { homedir as homedir16 } from "os";
11747
12451
  function readConfigEnv() {
11748
- if (!existsSync16(CONFIG_PATH5)) return {};
12452
+ if (!existsSync17(CONFIG_PATH5)) return {};
11749
12453
  const out = {};
11750
- for (const line of readFileSync13(CONFIG_PATH5, "utf-8").split("\n")) {
12454
+ for (const line of readFileSync14(CONFIG_PATH5, "utf-8").split("\n")) {
11751
12455
  const t = line.trim();
11752
12456
  if (!t || t.startsWith("#")) continue;
11753
12457
  const eq = t.indexOf("=");
@@ -11756,11 +12460,11 @@ function readConfigEnv() {
11756
12460
  return out;
11757
12461
  }
11758
12462
  function updateConfigValue(key, value) {
11759
- if (!existsSync16(CONFIG_PATH5)) {
12463
+ if (!existsSync17(CONFIG_PATH5)) {
11760
12464
  console.error("No config found. Run `synkro install` first.");
11761
12465
  process.exit(1);
11762
12466
  }
11763
- const lines = readFileSync13(CONFIG_PATH5, "utf-8").split("\n");
12467
+ const lines = readFileSync14(CONFIG_PATH5, "utf-8").split("\n");
11764
12468
  const pattern = new RegExp(`^${key}=`);
11765
12469
  let found = false;
11766
12470
  const updated = lines.map((line) => {
@@ -11771,7 +12475,7 @@ function updateConfigValue(key, value) {
11771
12475
  return line;
11772
12476
  });
11773
12477
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
11774
- writeFileSync10(CONFIG_PATH5, updated.join("\n"), "utf-8");
12478
+ writeFileSync11(CONFIG_PATH5, updated.join("\n"), "utf-8");
11775
12479
  }
11776
12480
  async function reconcileContainer() {
11777
12481
  const cfg = readConfigEnv();
@@ -11881,20 +12585,20 @@ var init_config = __esm({
11881
12585
  "cli/commands/config.ts"() {
11882
12586
  "use strict";
11883
12587
  init_stub();
11884
- SYNKRO_DIR6 = join15(homedir15(), ".synkro");
11885
- CONFIG_PATH5 = join15(SYNKRO_DIR6, "config.env");
12588
+ SYNKRO_DIR6 = join16(homedir16(), ".synkro");
12589
+ CONFIG_PATH5 = join16(SYNKRO_DIR6, "config.env");
11886
12590
  }
11887
12591
  });
11888
12592
 
11889
12593
  // cli/bootstrap.js
11890
- import { readFileSync as readFileSync14, existsSync as existsSync17 } from "fs";
12594
+ import { readFileSync as readFileSync15, existsSync as existsSync18 } from "fs";
11891
12595
  import { resolve as resolve3 } from "path";
11892
12596
  var envCandidates = [
11893
12597
  resolve3(process.env.HOME ?? "", ".synkro", "config.env")
11894
12598
  ];
11895
12599
  for (const envPath of envCandidates) {
11896
- if (!existsSync17(envPath)) continue;
11897
- const envContent = readFileSync14(envPath, "utf-8");
12600
+ if (!existsSync18(envPath)) continue;
12601
+ const envContent = readFileSync15(envPath, "utf-8");
11898
12602
  for (const line of envContent.split("\n")) {
11899
12603
  const trimmed = line.trim();
11900
12604
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -11909,7 +12613,7 @@ var args = process.argv.slice(2);
11909
12613
  var cmd = args[0] || "";
11910
12614
  var subArgs = args.slice(1);
11911
12615
  function printVersion() {
11912
- console.log("1.6.52");
12616
+ console.log("1.6.54");
11913
12617
  }
11914
12618
  function printHelp2() {
11915
12619
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -11925,6 +12629,7 @@ Commands:
11925
12629
  restart [opts] Safe restart (stop \u2192 start, data preserved)
11926
12630
  update Pull the latest container image and safely restart
11927
12631
  config Show or change grading + storage modes
12632
+ claude-desktop Monitor Claude Desktop conversations (local, macOS)
11928
12633
  version Show version
11929
12634
 
11930
12635
  config:
@@ -12020,6 +12725,11 @@ async function main() {
12020
12725
  await configCommand2(args.slice(1));
12021
12726
  break;
12022
12727
  }
12728
+ case "claude-desktop": {
12729
+ const { runClaudeDesktopTap: runClaudeDesktopTap2 } = await Promise.resolve().then(() => (init_claudeDesktopTap(), claudeDesktopTap_exports));
12730
+ await runClaudeDesktopTap2();
12731
+ break;
12732
+ }
12023
12733
  default: {
12024
12734
  console.error(`Unknown command: ${cmd}`);
12025
12735
  printHelp2();